Цели исследования:
На основе данных о постах на телеграм-канале "Кинопоиск", ежедневеом кол-ве подписок и отписок и комментариях к публикациям необходимо:
Загрузим необходимые библиотеки.
!pip install stop-words
!pip install nltk.tokenize
!pip install pymorphy2
Requirement already satisfied: stop-words in /usr/local/lib/python3.10/dist-packages (2018.7.23) ERROR: Could not find a version that satisfies the requirement nltk.tokenize (from versions: none) ERROR: No matching distribution found for nltk.tokenize Requirement already satisfied: pymorphy2 in /usr/local/lib/python3.10/dist-packages (0.9.1) Requirement already satisfied: dawg-python>=0.7.1 in /usr/local/lib/python3.10/dist-packages (from pymorphy2) (0.7.2) Requirement already satisfied: pymorphy2-dicts-ru<3.0,>=2.4 in /usr/local/lib/python3.10/dist-packages (from pymorphy2) (2.4.417127.4579844) Requirement already satisfied: docopt>=0.6 in /usr/local/lib/python3.10/dist-packages (from pymorphy2) (0.6.2)
#drive.mount('/content/drive')
import pandas as pd
import matplotlib.pyplot as plt
import datetime as dt
import seaborn as sns
import numpy as np
import requests
import urllib
import json
import re
import warnings
import scipy.stats as stats
import nltk
import string
import pymorphy2
import ast
import random
import statistics
from google.colab import drive
from datetime import datetime, timedelta
from nltk import word_tokenize
from nltk.probability import FreqDist
from nltk.corpus import stopwords
from wordcloud import WordCloud
from scipy.stats import pearsonr, spearmanr
nltk.download('punkt')
nltk.download('stopwords')
[nltk_data] Downloading package punkt to /root/nltk_data... [nltk_data] Package punkt is already up-to-date! [nltk_data] Downloading package stopwords to /root/nltk_data... [nltk_data] Package stopwords is already up-to-date!
True
Уберем ограничение по выводу строк, колонок и символов в записи и включаем игнорирование ошибок.
# Сброс ограничений на количество выводимых рядов
pd.set_option('display.max_rows', None)
# Сброс ограничений на число столбцов
pd.set_option('display.max_columns', None)
# Сброс ограничений на количество символов в записи
pd.set_option('display.max_colwidth', None)
#Игнорируем предупреждения Jupiter
warnings.filterwarnings('ignore')
Загружаем датасеты в датафреймы.
# подготавливаем ссылки для скачивания файлов с Яндекс.Диска.
# публичная ссылка на датасеты на яндекс диске
folder_url = 'https://disk.yandex.ru/d/2MvkGDrpIQjV7Q'
# названия файлов
file_url = ['comments_kinopisk_2023_01_18.csv',
'kinopisk_reposts_and_mentions_2023_19_01.csv',
'kinopisk_subscribers_detailed_2024-01-18.csv',
'kinopoisk_channel_posts_2023-01-21.csv',
'kinopoisk_subscribers_general_2024_18_01.csv']
# загружаем каждый файл в свой датафрейм
for i in range(0,5):
url = ('https://cloud-api.yandex.net/v1/disk/public/resources/download'
+ '?public_key='
+ urllib.parse.quote(folder_url)
+ '&path=/'
+ urllib.parse.quote(file_url[i]))
# запрос ссылки на скачивание
r = requests.get(url)
# 'парсинг' ссылки на скачивание
h = json.loads(r.text)['href']
if i == 0:
comments = pd.read_csv(h, index_col=[0])
elif i == 1:
reposts_and_mentions = pd.read_csv(h, index_col=[0])
elif i == 2:
subscribers_detailed = pd.read_csv(h, index_col=[0])
elif i == 3:
channel_posts = pd.read_csv(h, index_col=[0])
else:
subscribers_general = pd.read_csv(h, index_col=[0])
print(channel_posts.info())
display(channel_posts.head())
<class 'pandas.core.frame.DataFrame'> Int64Index: 23326 entries, 0 to 35656 Data columns (total 12 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 channel 23326 non-null object 1 id 23326 non-null int64 2 date 23326 non-null object 3 text 23326 non-null object 4 views 23325 non-null float64 5 reactions 9507 non-null object 6 with_media 22594 non-null object 7 forwarded 23325 non-null float64 8 replies 12233 non-null object 9 reactions_count 23326 non-null int64 10 comments 23326 non-null int64 11 type_attachment 22594 non-null object dtypes: float64(2), int64(3), object(7) memory usage: 2.3+ MB None
| channel | id | date | text | views | reactions | with_media | forwarded | replies | reactions_count | comments | type_attachment | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | https://t.me/kinopoisk | 37125 | 2024-01-21 08:02:21+00:00 | Какими подростками были актеры из «Трудных подростков»? А что они помнят о своей первой любви?\n\nПоговорили с кастом сериала — Милой Ершовой, Святославом Рогожаном, Анастасией Красовской (@nastitasti) и Дашей Верещагиной. \n\n[Вспомнили](https://www.kinopoisk.ru/media/article/4008982/) самый яркий съемочный день, любимых героев и не только!\n\n🔥 Подписывайтесь на [**«Кинопоиск»**](https://t.me/kinopoisk) | 12744.0 | {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 41, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 8, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🐳'}, 'count': 4, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👎'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤\u200d🔥'}, 'count': 1, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'recent_reactions': []} | {'_': 'MessageMediaWebPage', 'webpage': {'_': 'WebPage', 'id': 5475316184032202772, 'url': 'https://www.kinopoisk.ru/media/article/4008982/', 'display_url': 'kinopoisk.ru/media/article/4008982', 'hash': 0, 'has_large_media': True, 'type': 'photo', 'site_name': 'Кинопоиск', 'title': 'Актеры «Трудных подростков» вспоминают любимое в сериале и свое взросление. Есть история про котлеты! — Статьи на Кинопоиске', 'description': 'На\xa0Wink заканчивается пятый, финальный сезон «Трудных подростков». Кинопоиск поговорил с актерами\xa0Милой Ершовой, Святославом Рогожаном, Анастасией Красовской и\xa0Дашей Верещагиной о сериале, первой любви и том, какими они были подростками.', 'photo': {'_': 'Photo', 'id': 5867292056869778109, 'access_hash': -514397826960941877, 'file_reference': b'\x00e\xac\xd9\x08\x8d_4\xe4\xcc_\xf7O\xcb\xa7t\x9e\xfd&q\xe7', 'date': datetime.datetime(2024, 1, 21, 7, 5, 38, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01\x15(\xcd^0\xb8_\xc5E6E\x1b\xcf\xa7\xb5X\xca\xf6\xdb\x81\xdc\xf6\xfd(\x06=\xc0\x12\xa7=\xc7?\xd2\x80+\xacj\xc0\x9c\x91\x8azA\x1b6\x0b\x10*\xe248\xfe\x1ez\xf1\xff\x00\xd6\xa8\xd8\xc6\x18\xec(\x0f\xf9\xf6\xa0\x08\x96\xd1\x1b\x8d\xc78\xa2\x9d\x1b`\xee\xed\xedE\x00W~\x18\xafl\xd2\xa7a\xefE\x14\x00\xf9>U\x18\xea{\xd4hr\xd4QB\x13&NT\xfbQE\x14\x98\xd1'}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 168, 'size': 17059}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 420, 'size': 71309}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 1200, 'h': 630, 'sizes': [11670, 31872, 50766, 71132, 113336]}], 'dc_id': 4, 'has_stickers': False, 'video_sizes': []}, 'embed_url': None, 'embed_type': None, 'embed_width': None, 'embed_height': None, 'duration': None, 'author': None, 'document': None, 'cached_page': None, 'attributes': []}, 'force_large_media': False, 'force_small_media': False, 'manual': True, 'safe': False} | 12.0 | {'_': 'MessageReplies', 'replies': 5, 'replies_pts': 1171733, 'comments': True, 'recent_repliers': [{'_': 'PeerUser', 'user_id': 1677466820}, {'_': 'PeerUser', 'user_id': 241368205}, {'_': 'PeerUser', 'user_id': 5583002067}], 'channel_id': 1244684646, 'max_id': 730442, 'read_max_id': None} | 57 | 5 | MessageMediaWebPage |
| 4 | https://t.me/kinopoisk | 37121 | 2024-01-20 18:01:00+00:00 | Фильм дня — [**«Дневник Бриджит Джонс»**](https://www.kinopoisk.ru/film/621/) (18+) 🎬\n\nБриджит Джонс — 32 года, она не замужем, переживает из-за лишнего веса и хочет избавиться от вредных привычек. Чтобы привести свои жизнь в порядок, Бриджит решает вести дневник. Теперь героине предстоит выбрать одного из двух мужчин, с которыми ее сводит жизнь: скромного Марка Дарси или ее босса, харизматичного Дэниэла Кливера. \n\nПолная юмора мелодрама Шэрон Магуайр стала современной версией классического романа Джейн Остин «Гордость и предубеждение». А Бриджит — настоящий иконой ромкомов! Не зря Рене Зеллвегер даже номинировали на «Оскар» за эту роль.\n\n🔥 Подписывайтесь на [**«Кинопоиск»**](https://t.me/kinopoisk) | 49486.0 | {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 657, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 110, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤\u200d🔥'}, 'count': 55, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '💅'}, 'count': 31, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👎'}, 'count': 16, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🔥'}, 'count': 9, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🤪'}, 'count': 7, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🍌'}, 'count': 4, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '😱'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🐳'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '⚡'}, 'count': 1, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'recent_reactions': []} | {'_': 'MessageMediaPhoto', 'spoiler': False, 'photo': {'_': 'Photo', 'id': 5287402653648804899, 'access_hash': -2498810798362553488, 'file_reference': b'\x02>\xd5\xfa\xf9\x00\x00\x91\x01e\xac\xd9\x08va/57;\x89\xab\xfd5\xe6k\x18\x92\x97\x05', 'date': datetime.datetime(2024, 1, 20, 16, 56, 5, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01(\x1c\xd4,\x81\x8eI\xcf\xe3Ax\xff\x00\xbcx\xfa\xd3]\x0e\xfc\x84\xce}\xe9\x840\x19\xd8\xa0z\x9ac\xd0\x91\x9e \x033\xe0v94\xf1\x82\x01\x1c\x8f\xadd\xbe\xe7\xb8\xda\xe7(\x0e\x08\xad\x0bb\xdeH\xe0\x8eMM\xc2\xc2=\xa2\xbc\xcc\xec\xed\xcfa\xc5\te\x1cn\x18\x16$z\xd5\xaaJv\x15\xd9\x93-\xbf\xfaC\xe4n\\\xf1\x9e\xdd*\xfd\xbcl\x91\xf3&rs\xd2\xaaD\x86M^c\x9f\x919#=r\x05h\xf4\xe0`\n\x12\xd4\x1e\xaa\xc2\x06%\x98mn;\x9e\x86\x99\xe6\x9f\xf9\xe6\xdf\x95\x14S\x02+xc\x8egu\x8d\x95\x9f\x92I\xab\x19\xcfcE\x14\x80'}, {'_': 'PhotoSize', 'type': 'm', 'w': 225, 'h': 320, 'size': 17419}, {'_': 'PhotoSize', 'type': 'x', 'w': 562, 'h': 800, 'size': 63496}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 899, 'h': 1280, 'sizes': [13479, 30447, 37679, 52109, 89252]}], 'dc_id': 2, 'has_stickers': False, 'video_sizes': []}, 'ttl_seconds': None} | 161.0 | {'_': 'MessageReplies', 'replies': 85, 'replies_pts': 1171733, 'comments': True, 'recent_repliers': [{'_': 'PeerUser', 'user_id': 330254304}, {'_': 'PeerUser', 'user_id': 193630971}, {'_': 'PeerUser', 'user_id': 1295050489}], 'channel_id': 1244684646, 'max_id': 730439, 'read_max_id': None} | 896 | 85 | MessageMediaPhoto |
| 5 | https://t.me/kinopoisk | 37120 | 2024-01-20 16:01:16+00:00 | Кристоферу Нолану [вручат](https://www.hollywoodreporter.com/movies/movie-news/christopher-nolan-honorary-cesar-1235793056/) почетную премию «Сезар» за выдающиеся достижения в кинематографе — главный киноприз Франции. \n\nКак говорится в заявлении Французской киноакадемии, режиссер «переопределяет границы кинематографического совершенства и переносит нас за пределы пространства и времени — выходит за рамки кино, чтобы сделать его незабываемым». \n\nНолан получит награду 23 февраля на 49-й церемонии вручения премии «Сезар». Вместе с ним в этом году почетной статуэтки удостоится французская актриса и сценаристка Аньес Жауи. \n\nНе так давно Нолан получил «Золотой глобус» за лучшую режиссуру, а его «Оппенгеймер» выиграл в категории «Лучший фильм — драма». Картина Нолана о физике Роберте Оппенгеймере уже вошла в шорт-лист «Оскара» и, по прогнозам, должна [получить](https://www.kinopoisk.ru/media/article/4008731/) все основные номинации. \n\n__Заслужил?__\n❤️ — Давно! Гений\n👎 — Не заслужил, пока рано\n\nФото: Kevin Mazur / Getty Images\n\n🔥 Подписывайтесь на [**«Кинопоиск»**](https://t.me/kinopoisk) | 53713.0 | {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 1585, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👎'}, 'count': 82, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤\u200d🔥'}, 'count': 43, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 40, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🔥'}, 'count': 9, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🏆'}, 'count': 6, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🍾'}, 'count': 5, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🐳'}, 'count': 4, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🤝'}, 'count': 4, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🦄'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🥰'}, 'count': 1, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'recent_reactions': []} | {'_': 'MessageMediaPhoto', 'spoiler': False, 'photo': {'_': 'Photo', 'id': 5287757868919019751, 'access_hash': 5101285469346575516, 'file_reference': b'\x02>\xd5\xfa\xf9\x00\x00\x91\x00e\xac\xd9\x08\x0e\xc8\xdb\xdf\x0f6\xcf\x8b\x9d8\t\xe0M\x07\xe4\x08', 'date': datetime.datetime(2024, 1, 20, 15, 24, 15, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01\x1b(i;\xe6X\xc1 (\xf9s\xf8\xd3\x1d\x17\xc8D\xfe\xe6r\xd8\xe9\xcdGm\x1c\xb3\xc9\xb9@\xe3\xd4\xd2\xcd\x88$a"\x9c\x9ev\x83\xc1\xa9[\x94?\xca2|\xef\xf7@\xc6\rUr\x07\xfa\xbe\xaax\xfaU\xd8\xcb5\x92\xb7\xcb\xb8\xe7\x0b\xeb\x8a\xa2\x88g\x94\xec\\\x13\xce3L\x0b%\x03\xa9\xd9\xc1\x034S\xe1\x8b\xc8<\xb3na\x82\x98\xa2\xa6\xf6\x1d\xae\x16\x04\xe1\xb0\xf8$\xe6\x9bz\x16B\xa4\xbf$\xff\x00\x15VN\x06G\\\xd4\xd7\x00\x10r:\x01\x8a}Ct6\x160\\&\xf7\x1b9\xc7\xa7J-&X\x98\xab\x00\x03\x7f\x1fu\xa8\xb0\x1a.y\xda\x0e=\xb9\xa8\x8fJ\xad\xc8\xd8\xb8\xd2\xce\xf2\xaa\x86#\x92\tS\xd7\x14S\xed\t\x16D\xf7\x07\x8f\xce\x8a\x96ZW?'}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 214, 'size': 19837}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 534, 'size': 75950}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 1024, 'h': 683, 'sizes': [11012, 26236, 31821, 46980, 81018]}], 'dc_id': 2, 'has_stickers': False, 'video_sizes': []}, 'ttl_seconds': None} | 136.0 | {'_': 'MessageReplies', 'replies': 68, 'replies_pts': 1171733, 'comments': True, 'recent_repliers': [{'_': 'PeerUser', 'user_id': 497409694}, {'_': 'PeerUser', 'user_id': 431417457}, {'_': 'PeerUser', 'user_id': 432080771}], 'channel_id': 1244684646, 'max_id': 730404, 'read_max_id': None} | 1782 | 68 | MessageMediaPhoto |
| 6 | https://t.me/kinopoisk | 37119 | 2024-01-20 14:20:04+00:00 | Отгадайте фильм: в жизни грустной девочки появляется незадачливый молодой отец — и все это режиссерский дебют британской кинематографистки. \n\nНет, это не «Солнце мое», а «Задира» — пронзительное драмеди с Харрисом Диккинсоном из «Треугольника печали»! Сняла фильм Шарлотта Риган, и он уже успел стать победителем «Сандэнса». \n\nКакой получилась эта история о взрослении и переживании горя, читайте [здесь](https://www.kinopoisk.ru/media/news/4008980/). \n\n🔥 Подписывайтесь на [**«Кинопоиск»**](https://t.me/kinopoisk) | 55890.0 | {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 124, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 31, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤\u200d🔥'}, 'count': 15, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👎'}, 'count': 8, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '⚡'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🔥'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '😱'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🐳'}, 'count': 1, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🦄'}, 'count': 1, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'recent_reactions': []} | {'_': 'MessageMediaWebPage', 'webpage': {'_': 'WebPage', 'id': 5475316182972329094, 'url': 'https://www.kinopoisk.ru/media/news/4008980/', 'display_url': 'kinopoisk.ru/media/news/4008980', 'hash': 566396092, 'has_large_media': True, 'type': 'photo', 'site_name': 'Кинопоиск', 'title': '«Задира»: как «Солнце мое», только\xa0без меланхолии', 'description': '«Задира» — удивительный дебют Шарлотты Риган, чем-то похожий на «Солнце мое». В изобретательной фантазии про горе-папашу и дочь-пацанку отца играет Харрис Дикинсон из «Треугольника печали».', 'photo': {'_': 'Photo', 'id': 5858298657344959169, 'access_hash': -7508799176150459187, 'file_reference': b'\x00e\xac\xd9\x08m;v\xba\x01+\x0fN[0:\xdb\xdf\x84#\x99', 'date': datetime.datetime(2024, 1, 18, 13, 54, 36, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01\x15(\xaf\x1d\xb2\x80\x03t\xf5\xda)>\xc9\x03\xbb\r\xec\x08\xec\x06*\xd4n\xa5\x07#\xf1\xff\x00\xf5R\xeeQ l\x03\xef\x8f\xfe\xb5\x00QkH\xd6@\x9b\xd8\x93\xe9Ok\x18V=\xe5\xde\xa5\xba\x91H\x0e\xb8\xf48\xa8\x85\xc0e\nFH\xe4\n\x068\xe9\xd1\xec\xdc\x1d\xbag\x9a*U\xb82F~N\xa3\xd6\x8a\x04G\x19\xebN-\xb5\t\xeb\xcd\x14S\x02\xb0vRT\x1e:\xf3L\x90lT\x90\x1c1\xa2\x8a@=\x1b\xf8\x86A9\xcf4QE\x00\x7f'}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 168, 'size': 15016}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 420, 'size': 60394}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 1200, 'h': 630, 'sizes': [10919, 28022, 42073, 58652, 94776]}], 'dc_id': 4, 'has_stickers': False, 'video_sizes': []}, 'embed_url': None, 'embed_type': None, 'embed_width': None, 'embed_height': None, 'duration': None, 'author': None, 'document': None, 'cached_page': {'_': 'Page', 'url': 'https://www.kinopoisk.ru/media/news/4008980/', 'blocks': [{'_': 'PageBlockChannel', 'channel': {'_': 'Channel', 'id': 1054210809, 'title': 'Кинопоиск: фильмы и сериалы', 'photo': {'_': 'ChatPhoto', 'photo_id': 5425084126744136490, 'dc_id': 2, 'has_video': False, 'stripped_thumb': b'\x01\x08\x08\xcf\x88C\xe5\xae\xe2\xbb\xbd\xe8\xa2\x8a\x87\x1f2\xb9\x8f'}, 'date': datetime.datetime(2016, 5, 13, 11, 58, 50, tzinfo=datetime.timezone.utc), 'creator': False, 'left': False, 'broadcast': True, 'verified': True, 'megagroup': False, 'restricted': False, 'signatures': False, 'min': True, 'scam': False, 'has_link': True, 'has_geo': False, 'slowmode_enabled': False, 'call_active': False, 'call_not_empty': False, 'fake': False, 'gigagroup': False, 'noforwards': False, 'join_to_send': False, 'join_request': False, 'forum': False, 'stories_hidden': False, 'stories_hidden_min': True, 'stories_unavailable': True, 'access_hash': 675089040032349539, 'username': 'kinopoisk', 'restriction_reason': [], 'admin_rights': None, 'banned_rights': None, 'default_banned_rights': None, 'participants_count': None, 'usernames': [], 'stories_max_id': None, 'color': None}}, {'_': 'PageBlockTitle', 'text': {'_': 'TextPlain', 'text': '«Задира»: как «Солнце мое», только\xa0без меланхолии'}}, {'_': 'PageBlockAuthorDate', 'author': {'_': 'TextEmpty'}, 'published_date': datetime.datetime(2024, 1, 18, 0, 0, tzinfo=datetime.timezone.utc)}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextPlain', 'text': '18\xa0января в\xa0российский прокат вышел режиссерский дебют '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Шарлотты Риган'}, 'url': 'https://www.kinopoisk.ru/name/3866796', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' про отношения 12-летней пацанки с\xa0юным папашей. О\xa0победителе «Сандэнса» с\xa0модной звездой '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Треугольника печали»'}, 'url': 'https://www.kinopoisk.ru/film/1348487/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Харрисом Дикинсоном'}, 'url': 'https://www.kinopoisk.ru/name/3498137', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' рассказывает Михаил Моркин.'}]}}, {'_': 'PageBlockHeader', 'text': {'_': 'TextPlain', 'text': 'О\xa0чем это'}}, {'_': 'PageBlockPhoto', 'photo_id': 5858576485894435563, 'caption': {'_': 'PageCaption', 'text': {'_': 'TextPlain', 'text': 'Харрис Дикинсон и Лола Кэмпбелл'}, 'credit': {'_': 'TextEmpty'}}, 'url': None, 'webpage_id': None}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextPlain', 'text': 'После смерти матери 12-летняя Джорджи (впечатляющий дебют '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Лолы Кэмпбелл'}, 'url': 'https://www.kinopoisk.ru/name/6817963', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ') проводит лето одна в\xa0крохотной квартирке безликого жилмассива в\xa0Эссексе. Социальные службы уверены, что она живет с\xa0дядей по\xa0имени Уинстон Черчилль, доверчиво покупаясь на\xa0аудиосообщения а-ля «У\xa0Джорджи все хорошо», которые смышленная хитрюга просит надиктовать добродушного продавца в\xa0продуктовом. Деньги на\xa0пропитание и\xa0арендную плату она достает, промышляя кражей велосипедов с\xa0дружбаном-соседом Али (не\xa0менее убедительный дебютант '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Алин Узун'}, 'url': 'https://www.kinopoisk.ru/name/6817964', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '). В\xa0общем, быт сироты более-менее налажен, осталось только пройти еще пару стадий принятия горя. И\xa0тут в\xa0жизнь Джорджи влезает крашеный блондин Джейсон (Харрис Дикинсон из\xa0«Треугольника печали» и\xa0'}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Убийства на\xa0краю света»'}, 'url': 'https://www.kinopoisk.ru/film/4672773/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ')\xa0— ее\xa0молодой папаша, которого она никогда в\xa0жизни не\xa0видела. Теперь они будут жить вместе.'}]}}, {'_': 'PageBlockHeader', 'text': {'_': 'TextPlain', 'text': 'Кто это снял'}}, {'_': 'PageBlockPhoto', 'photo_id': 5858225634310992646, 'caption': {'_': 'PageCaption', 'text': {'_': 'TextPlain', 'text': 'Харрис Дикинсон'}, 'credit': {'_': 'TextEmpty'}}, 'url': None, 'webpage_id': None}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Задира»'}, 'url': 'https://www.kinopoisk.ru/film/4910100/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '\xa0— фестивальный дебют молодой британской режиссерки Шарлотты Риган. Фильм об\xa0отношениях не\xa0по\xa0годам смышленой девочки с\xa0молодым отцом-разгильдяем напоминает описание '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Солнца моего»'}, 'url': 'https://www.kinopoisk.ru/film/4948281/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '. Только дебют Шарлотты Уэллс стартовал в\xa0Каннах, а\xa0фильм Риган прогремел год назад на\xa0«Сандэнсе», где даже получил приз большого жюри. Но\xa0более важное различие между «Задирой» и\xa0«Солнцем»\xa0— в\xa0интонации. «Солнце мое» было грустной и\xa0пронзительной автобиографией, а\xa0«Задира»\xa0— бойкая и\xa0стремительная (всего 80\xa0минут!) фантазия (впрочем, тоже отчасти автобиографическая).'}]}}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextPlain', 'text': 'Оператором фильма выступила '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Молли Мэннинг Уокер'}, 'url': 'https://www.kinopoisk.ru/name/2819555', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '\xa0— еще одна молодая кинематографистка, чей режиссерский дебют '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Как заниматься сексом»'}, 'url': 'https://www.kinopoisk.ru/film/5305440/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' про потерю девственности победил\xa0в прошлом году в\xa0каннском конкурсе «Особый взгляд».'}]}}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextPlain', 'text': 'Харрис Дикинсон продолжает укреплять свое амплуа добродушного, растерянного и\xa0ненадежного простака. Мало кто из\xa0современных молодых актеров умеет так обаятельно изображать оболтусов. 27-летний британец умудряется удивительным образом совмещать в\xa0себе черты сразу всех торчков из\xa0«'}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'На\xa0игле»:'}, 'url': 'https://www.kinopoisk.ru/film/515/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' привлекательность '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Юэна Макгрегора'}, 'url': 'https://www.kinopoisk.ru/name/7590', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ', неуклюжесть '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Юэна Бремнера'}, 'url': 'https://www.kinopoisk.ru/name/31970', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ', дерзость '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Джонни Ли\xa0Миллера'}, 'url': 'https://www.kinopoisk.ru/name/14843', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' и\xa0трагичность '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Кевина МакКидда'}, 'url': 'https://www.kinopoisk.ru/name/38714', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '. Однако настоящее открытие фильма\xa0— живая и\xa0очаровательная Лола Кэмпбелл, чья пробивная героиня заставит вас купить мешковатую футболку клуба «Вест Хэм Юнайтед» и\xa0научит произносить слово «proper» (четкий) с\xa0тяжелейшим акцентом жителей Северного Лондона.'}]}}, {'_': 'PageBlockHeader', 'text': {'_': 'TextPlain', 'text': 'Как это снято'}}, {'_': 'PageBlockPhoto', 'photo_id': 5858565473598288587, 'caption': {'_': 'PageCaption', 'text': {'_': 'TextPlain', 'text': 'Алин Узун и Лола Кэмпбелл'}, 'credit': {'_': 'TextEmpty'}}, 'url': None, 'webpage_id': None}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextPlain', 'text': 'Несмотря на\xa0сюжет и\xa0локации, здесь почти не\xa0пахнет неореализмом '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Похитителей велосипедов»'}, 'url': 'https://www.kinopoisk.ru/film/432/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ', «драмой кухонной мойки» и\xa0реалистичными жизнеописаниями рабочего класса под авторством '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Кена Лоуча'}, 'url': 'https://www.kinopoisk.ru/name/38774', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '. Если это реализм, но\xa0скорее уж\xa0магический, а\xa0не\xa0социальный. Риган находит место для говорящих пауков Наполеона и\xa0Александра Великого (они общаются с\xa0помощью речевых пузырей из\xa0комиксов) и для чудо-башни из\xa0всякого мусора и\xa0металлолома, которую Джорджи возводит прямо у\xa0себя дома. У\xa0девчонки вообще богатая фантазия: своего отца она представляет то\xa0в\xa0образе вампира, то как гангстера, то как заключенного. Недолго думая, Джейсон тоже начинает красть велосипеды, образуя с\xa0дочкой дурашливый криминальный дуэт, напоминающий о\xa0'}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Бумажной луне»'}, 'url': 'https://www.kinopoisk.ru/film/5033/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Питера Богдановича'}, 'url': 'https://www.kinopoisk.ru/name/3010', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '.'}]}}, {'_': 'PageBlockHeader', 'text': {'_': 'TextPlain', 'text': 'Вердикт'}}, {'_': 'PageBlockPhoto', 'photo_id': 5858252001115222684, 'caption': {'_': 'PageCaption', 'text': {'_': 'TextEmpty'}, 'credit': {'_': 'TextEmpty'}}, 'url': None, 'webpage_id': None}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextPlain', 'text': '«Задира» с\xa0легкой интонацией рассказывает не\xa0только о\xa0переживании смерти и\xa0быстром взрослении. Трудный подросток Джорджи уверена, что уже прошла стадии отрицания, гнева и\xa0торга, а\xa0значит, депрессия и\xa0принятие не\xa0займут много времени. Пока она, правда, все еще боится передвигать мамины подушки и\xa0смотреть без нее Netflix. При этом не\xa0очень понятно, кто здесь взрослеет больше — хулиганка Джорджи или незадачливый Джейсон, который не\xa0умеет варить кашу и\xa0включать стиральную машину.'}}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextPlain', 'text': 'Через душевные разговоры, добрую иронию и\xa0неловкие приключения дисфункциональная семья воссоединяется. Оказывается, сила воображения и\xa0постепенное доверие могут излечить суровую драму жизни, которую кинематографисты привыкли скорбно оплакивать.'}}, {'_': 'PageBlockDivider'}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextBold', 'text': {'_': 'TextPlain', 'text': 'Автор:'}}, {'_': 'TextPlain', 'text': ' Михаил Моркин'}]}}], 'photos': [{'_': 'Photo', 'id': 5858576485894435563, 'access_hash': -6347752733489840788, 'file_reference': b'\x00e\xac\xd9\x08v\xcel\xdb"\xc1s\xa2)b\xcd\xaf\xf1^\xd51', 'date': datetime.datetime(2024, 1, 18, 13, 54, 36, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01\x17(\x9e\x02\xb2\xc5\xbc\xe4`\xe0\xf3P\x19\x8f\xda\xbc\xb5\xe53\x8a\xae\x8c\xc0\xcb\x18<\x81\x91\xf5\xa8\xed\xeeDs\xa4\x8e\xb9\x1d\xff\x00*\x9b\x01j\xe5\x00\x99\x87\xd2\xac\xc5\x101)$\x8e*\x9c\xb7),\xac\xc07>\xd5\r\xcc\xfb\xf6\x14c\x85\x18\xa2\xcc\r\x1b\x85\x10\xc2\xee\t\xe0w\xa2\xa1\x9e\xe5$\xd3J\x97\x1b\xc8\x1f\x8f4P\x90\x15 B\x08vbX\xd4R\x08\xc6p\xa7\xf3\xa2\x8a`\x86\x996\xe0(\xc5 _\x97&\x8a)\xa0cs\xf2\xe3\xb0\xa2\x8a(\x19'}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 180, 'size': 13787}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 450, 'size': 56659}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 960, 'h': 540, 'sizes': [7915, 18420, 23943, 35293, 71746]}], 'dc_id': 4, 'has_stickers': False, 'video_sizes': []}, {'_': 'Photo', 'id': 5858225634310992646, 'access_hash': 4063387891663174401, 'file_reference': b'\x00e\xac\xd9\x08\x07\xa5\xb4c\r\xdd\xb0\xf3\x9f7\xa7\xd4d\x19\xce\xf8', 'date': datetime.datetime(2024, 1, 18, 13, 54, 36, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b"\x01\x17(\xa9\x14\x9d;c4\x84\x16'\xf9Th8\xfd*E<\x8a\x8b\x01m-\xa3\xc6\x15\xf2\xde\xbd\xaa\xab|\x8eT\xf5\x07\x15,o\xb5\xcf$\x01\xcdW'$\x9ae\x0f\xdepGj)\x94S\x11u`\x0b\x04K\x93\x97\xe4\xfd:\xd4\xa8\x91Ko\x98\x97\nx\r\xde\x8a(\x10\x90Z\x94\xdc\xccA\xf4\x15\x1d\xc4\x91\xa4\x8e\xaa\xa42\x8c\xe3\xb1\xff\x009\xa2\x8a\x10=J\xc6\xe4\x0c|\x9f\xad\x14QT\x89g"}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 180, 'size': 14401}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 450, 'size': 59395}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 960, 'h': 540, 'sizes': [8609, 21493, 26041, 37778, 74823]}], 'dc_id': 4, 'has_stickers': False, 'video_sizes': []}, {'_': 'Photo', 'id': 5858565473598288587, 'access_hash': -1911332357782444587, 'file_reference': b'\x00e\xac\xd9\x08\xf1o\xefc|\xb4Z\xa7\x9e\xad\xf1\xea\xb0\xda\xfe^', 'date': datetime.datetime(2024, 1, 18, 13, 54, 36, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01\x17(\xa9\r\xc7\xcc\xfd\x81;\xba\xd4\xc2d\x92@\x10\x9f\xc6\xaa\x04\xc3\xb7\xf7NE,M\xe5H\x19O#\xbd!\xa6^r|\xc1\x9c\x0e\xbf\xca\xa5\x8e^\x99`\x07\xbdT2\xbc\x87.s\xf8S]\xb2\x84qH.[i\x13\xcc\xc0l\x93\xd2\x8a\xce\x1b\x82\xee\xcfN\x9e\xf4P\x03\x1c\xe4\x92:\nL\xd1EZ!\x93\x05m\x81\x89\xe2\xa2\xdf\xb4\xe3\x19\x19\xa2\x8aE E\xdf \x1d\x01\xa2\x8a(\x03'}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 180, 'size': 14781}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 450, 'size': 59457}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 960, 'h': 540, 'sizes': [8663, 21364, 26389, 37691, 75170]}], 'dc_id': 4, 'has_stickers': False, 'video_sizes': []}, {'_': 'Photo', 'id': 5858252001115222684, 'access_hash': -7880929634662159804, 'file_reference': b'\x00e\xac\xd9\x08\xd2\xca\x0c\x99\x93\xedqp\xd4>?\xfb\xc8\x96\r\x97', 'date': datetime.datetime(2024, 1, 18, 13, 54, 56, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01\x17(\xb3\x05\xdcs\xf0\x0e\x1b\xd0\xd3\xd6dwdV\x05\x87Z\xc3\x8f\xcc\x8eM\xc1[\xf0\xa9\xe3\x90\xc6\xdb\xd5\n\x9e\x99\xcdM\xc5cc4f\xb2\x1a\xe9\xf9$\xc9\xeb\xc1\xa2)\xe6BJd\x86\xe7\x9ei\xdc\rcET[\xbf\x90oF\xdd\xdf\x02\x8a.\x80\x88?\xa0\xc5)u\x1cu\xf5\xa2\x8a\xc4\xa1Cn\x1ftc\xbd\n\xdb\x06\xdd\xa2\x8a(\x18\xa1\xd7n\x08\xe6\x8a(\xa0\x0f'}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 180, 'size': 15656}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 450, 'size': 72033}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 960, 'h': 540, 'sizes': [7400, 19788, 31754, 48143, 88968]}], 'dc_id': 4, 'has_stickers': False, 'video_sizes': []}], 'documents': [], 'part': False, 'rtl': False, 'v2': False, 'views': None}, 'attributes': []}, 'force_large_media': False, 'force_small_media': False, 'manual': True, 'safe': False} | 148.0 | {'_': 'MessageReplies', 'replies': 12, 'replies_pts': 1171733, 'comments': True, 'recent_repliers': [{'_': 'PeerUser', 'user_id': 1220823926}, {'_': 'PeerUser', 'user_id': 2085283023}, {'_': 'PeerUser', 'user_id': 6754389370}], 'channel_id': 1244684646, 'max_id': 730380, 'read_max_id': None} | 189 | 12 | MessageMediaWebPage |
| 7 | https://t.me/kinopoisk | 37118 | 2024-01-20 10:48:48+00:00 | Правда или фейк? 🧐\n\n#ДежурныйПоКинопоиску Сергей Сироткин пытается отличить настоящие новости от выдуманных.\n\nПолный [выпуск нашего шоу](https://youtu.be/KOhDZReSQrs?si=wL9bwB_vJthBC6qp) — уже на YouTube-канале «Кинопоиск Экстра»!\n\n🔥 Подписывайтесь на [**«Кинопоиск»**](https://t.me/kinopoisk) | 60348.0 | {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 122, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 50, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '😁'}, 'count': 15, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🤩'}, 'count': 8, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👎'}, 'count': 5, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🔥'}, 'count': 4, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🍓'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🐳'}, 'count': 2, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🍾'}, 'count': 1, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'recent_reactions': []} | {'_': 'MessageMediaDocument', 'nopremium': False, 'spoiler': False, 'document': {'_': 'Document', 'id': 5287757868462785694, 'access_hash': 2933086641076259676, 'file_reference': b'\x02>\xd5\xfa\xf9\x00\x00\x90\xfee\xac\xd9\x08]w\xa7\xb8Vn$\x8a\xf8\xed9\x84-Co\xd6', 'date': datetime.datetime(2024, 1, 20, 10, 48, 48, tzinfo=datetime.timezone.utc), 'mime_type': 'video/mp4', 'size': 128736695, 'dc_id': 2, 'attributes': [{'_': 'DocumentAttributeVideo', 'duration': 59.8, 'w': 1080, 'h': 1920, 'round_message': False, 'supports_streaming': True, 'nosound': False, 'preload_prefix_size': None}, {'_': 'DocumentAttributeFilename', 'file_name': 'dezh_sirotkin_reels_04.mp4'}], 'thumbs': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01(\x16\xa5\x8ai\x15&)\x08\xac\xeeob,QN"\x8a\xab\x93bZCI\xc9<PCz\x1f\xca\xa0\xd0i\xa2\x83EPXP\xc4t&\x8d\xed\xfd\xe3\xf9\xd1E\x16\x00$\x9e\xa4\x9a(\xa2\x80?'}, {'_': 'PhotoSize', 'type': 'm', 'w': 180, 'h': 320, 'size': 4447}], 'video_thumbs': []}, 'alt_document': None, 'ttl_seconds': None} | 27.0 | {'_': 'MessageReplies', 'replies': 3, 'replies_pts': 1171733, 'comments': True, 'recent_repliers': [{'_': 'PeerUser', 'user_id': 6555146753}, {'_': 'PeerUser', 'user_id': 1448648552}, {'_': 'PeerUser', 'user_id': 6224725917}], 'channel_id': 1244684646, 'max_id': 730147, 'read_max_id': None} | 210 | 3 | MessageMediaDocument |
В нашем распоряжении датафрйем channel_posts с 13 колонками и 23326 строками.
Сведения о постах в телеграм канале
Кинопоиска
channel - неинформативная колонка - название каналаid - идентификатор поста Соответствует post_id в файле с комментариями к постамdate - дата и время публикации поста в формате CTE (+2 часа для получения московского времени)text - текст постаviews - количество просмотров постаreactions - словарь с реакциями (эмоджи/смайлы) на пост Дает информацию о типе смайла и его количествеwith_media - дает представление о прикрепленном к посту документе Как правило MessageMediaPhoto - фото MessageMediaDocument - видео MessageMediaWebPage - ссылкаforwarded - сколько раз пересылался постreplies - словарь с количеством комментариев к постуreactions_count - количество реакций/эмоджи на пост Столбец получен из столбца reactions путем суммирование количества всех реакцийcomments - количество комментариев к посту Столбец получен на основе Repliestype_attachement - вид прикрепленного к посту документа Получен из словаря в With mediaprint(comments.info())
display(comments.head())
<class 'pandas.core.frame.DataFrame'> Int64Index: 139522 entries, 0 to 17924 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Unnamed: 0 139522 non-null int64 1 post_id 139522 non-null int64 2 date_comment 139522 non-null object 3 text_comment 132192 non-null object dtypes: int64(2), object(2) memory usage: 5.3+ MB None
| Unnamed: 0 | post_id | date_comment | text_comment | |
|---|---|---|---|---|
| 0 | 0 | 37068 | 2024-01-18 09:34:31+00:00 | От бесстыжих к медведю - так это скорее не путь, а спуск |
| 1 | 1 | 37068 | 2024-01-18 09:35:00+00:00 | учился орать FUCK! |
| 2 | 2 | 37068 | 2024-01-18 09:35:11+00:00 | Верните бестыжих |
| 3 | 3 | 37068 | 2024-01-18 09:36:34+00:00 | От липа из бесстыжих до рекламы кельвин кляйн |
| 4 | 4 | 37068 | 2024-01-18 09:37:41+00:00 | этот навык был освоен еще в "бесстыжих"🧡 |
Датафрейм comments с 5 колонками и 139522 строками.
Текстовые комментарии к постам
post_id - идентификатор поста, к которому был написан комментарий Соответствует ID в файле subscribers_generaldate_comment - дата публикации комментария в формате CTEtext_comment - текстовое содержание комментарияprint(subscribers_general.info())
display(subscribers_general.head())
<class 'pandas.core.frame.DataFrame'> Int64Index: 520 entries, 0 to 519 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 date 520 non-null object 1 subscribers 520 non-null int64 2 changes 520 non-null int64 dtypes: int64(2), object(1) memory usage: 16.2+ KB None
| date | subscribers | changes | |
|---|---|---|---|
| 0 | 18.01.24 | 551378 | 415 |
| 1 | 17.01.24 | 550963 | 2061 |
| 2 | 16.01.24 | 548902 | 2043 |
| 3 | 15.01.24 | 546859 | 2539 |
| 4 | 14.01.24 | 544320 | 2021 |
Датафрейм subscribers_general 4 колонки и 520 строк.
Общая информация о подписчиках канала в разбивке по дням
date - датаsubscribers - суммарное количество подписок на эту датуchanges - разница в количестве подписчиков между текущей и предыдущей датамиprint(subscribers_detailed.info())
display(subscribers_detailed.head())
<class 'pandas.core.frame.DataFrame'> Int64Index: 12282 entries, 0 to 12281 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 date 12282 non-null object 1 time 12282 non-null object 2 subscribers 12282 non-null int64 3 unsubscribed 12282 non-null int64 dtypes: int64(2), object(2) memory usage: 479.8+ KB None
| date | time | subscribers | unsubscribed | |
|---|---|---|---|---|
| 0 | Чт, 18 Jan | 11:29 | 204 | -300 |
| 1 | Чт, 18 Jan | 11:00 | 373 | -366 |
| 2 | Чт, 18 Jan | 10:00 | 333 | -150 |
| 3 | Чт, 18 Jan | 09:00 | 211 | -133 |
| 4 | Чт, 18 Jan | 08:00 | 152 | -90 |
Датафрейм subscribers_detailed 5 колонок и 12282 строки.
Представлена информация о подписчиках канала и отписках
date - датаtime - времяsubscribers - количество подписавшихсяunsubscribed - количество отписокprint(reposts_and_mentions.info())
display(reposts_and_mentions.head())
<class 'pandas.core.frame.DataFrame'> Int64Index: 2306 entries, 0 to 2305 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 channel 2306 non-null object 1 number_subscribers 2306 non-null int64 2 action 2306 non-null object 3 date 2306 non-null object dtypes: int64(1), object(3) memory usage: 90.1+ KB None
| channel | number_subscribers | action | date | |
|---|---|---|---|---|
| 0 | Обсуждаем фильмы. Поиском кино не занимаемся | 1358 | репостнул запись | 19 Jan, 10:02 |
| 1 | 🎬 КиноДед | 1409 | репостнул запись | 18 Jan, 23:33 |
| 2 | супер8 | 22276 | упомянул канал | 18 Jan, 22:48 |
| 3 | Стримми | 13152 | упомянул канал | 18 Jan, 22:35 |
| 4 | Обсуждаем фильмы. Поиском кино не занимаемся | 1359 | упомянул канал | 18 Jan, 21:02 |
Датафрейм reposts_and_mentions - 5 колонок, 2306 строк.
Данные за 3 месяца о телеграм каналах и группах, которые репостили посты Кинопоиска, либо упоминали Кинопоиск в своих постах.
channel - канал или группа, который сделал репост/упоминание Кинопоискаnumber_subscribers - количество подписчиков этого каналаaction - тип действия - репост или упоминаниеdate- когда было произведено действие.В датафрейме comments у нас есть столбец Unnamed который появился, возможно, при создании датасета, и дублируют индекс. Удалим его.
comments = comments.drop(columns=['Unnamed: 0'])
print(comments.columns)
Index(['post_id', 'date_comment', 'text_comment'], dtype='object')
Мы удалили лишние колонки.
Посмотрим на количество пропусков в процентном соотношении в каждом датафрейме.
for i in (channel_posts, comments, subscribers_general, subscribers_detailed, reposts_and_mentions):
display(pd.DataFrame(round(i.isna().mean()*100,1)).style.background_gradient('coolwarm'))
| 0 | |
|---|---|
| channel | 0.000000 |
| id | 0.000000 |
| date | 0.000000 |
| text | 0.000000 |
| views | 0.000000 |
| reactions | 59.200000 |
| with_media | 3.100000 |
| forwarded | 0.000000 |
| replies | 47.600000 |
| reactions_count | 0.000000 |
| comments | 0.000000 |
| type_attachment | 3.100000 |
| 0 | |
|---|---|
| post_id | 0.000000 |
| date_comment | 0.000000 |
| text_comment | 5.300000 |
| 0 | |
|---|---|
| date | 0.000000 |
| subscribers | 0.000000 |
| changes | 0.000000 |
| 0 | |
|---|---|
| date | 0.000000 |
| time | 0.000000 |
| subscribers | 0.000000 |
| unsubscribed | 0.000000 |
| 0 | |
|---|---|
| channel | 0.000000 |
| number_subscribers | 0.000000 |
| action | 0.000000 |
| date | 0.000000 |
Мы видим, что в датафреймах subscribers_general, subscribers_detailed, reposts_and_mentions пропусков нет.
В датафрейме comments в колонке text_comment 5,3% пропусков, возможно, так тображаются комментарии со стикерами, эмодзи или гифками.
В датафрейме channel_posts в колонке reactions 53% строки с пропусками и в колонке replies 47,6% строк с пропусками. Возможно, реакции и комментарии были подключены позже, чем начало датафрейма.
Из новостей обновлений Telegram мы знаем, что комментарии были включены 30 сентября 2020 года, а реакции 30 декабря 2021 года. Если данные у нас есть раньше этих дат, то пропуски логичны.
Проверим датафреймы на явные дубли.
for i in (channel_posts, comments, subscribers_general, subscribers_detailed, reposts_and_mentions):
print(f'Явных дублей в датафреме {i.duplicated().sum()}')
print(f'Доля дублей от всего датафрейма {(i.duplicated().sum()*100/len(i)).round(2)} %')
Явных дублей в датафреме 0 Доля дублей от всего датафрейма 0.0 % Явных дублей в датафреме 620 Доля дублей от всего датафрейма 0.44 % Явных дублей в датафреме 0 Доля дублей от всего датафрейма 0.0 % Явных дублей в датафреме 0 Доля дублей от всего датафрейма 0.0 % Явных дублей в датафреме 85 Доля дублей от всего датафрейма 3.69 %
Мы видим, что в датафрейме comments есть 620 явныхдублей, они могли возникнуть при сборе информации, т.к. не могли быть написаны несколько одинковых комментариев в одну секунду, и это 0,44% от всего датафрейма, их можно удалить. А в датафрейме reposts_and_mentions есть 85 явных дублей, это может говорить, что один канал даважды за одну минуту упомянул кинопоиск, или сделал репост, что возможно, эти дубли мы оставим.
comments = comments.drop_duplicates()
print(f'Явных дублей в датафреме {comments.duplicated().sum()}')
print(f'Доля дублей от всего датафрейма {(comments.duplicated().sum()*100/len(comments)).round(2)} %')
Явных дублей в датафреме 0 Доля дублей от всего датафрейма 0.0 %
Мы изюавились от явных дублей в датафрейме comments.
Проверим, есть ли в колонке channel каналы, кроме кинопоиска.
print('Каналы в колонке channel', channel_posts['channel'].unique())
Каналы в колонке channel ['https://t.me/kinopoisk']
Мы видим, что в этой колонке только один канал - кинопоиска, это не пригодится нам в дальнейшем анализе, поэтому, чтобы облегчить немного датафрейм, мы удалим эту колонку. Также удалим колонку with_media, она содержит информацию о приложенных медиа, в данном анализе нам пригодится только тип медиа, который указан в type_attachment
channel_posts = channel_posts.drop(columns=['channel', 'with_media'])
print(channel_posts.columns)
Index(['id', 'date', 'text', 'views', 'reactions', 'forwarded', 'replies',
'reactions_count', 'comments', 'type_attachment'],
dtype='object')
Колонка удалена.
Проверим, нет ли дублей в колонке id, т.е. не попал ли один пост дважды к нам в анализ.
temp = channel_posts.groupby(by='id').agg({'date':'count'})
print('Повторяющихся id в датафрейме', len(temp.query('date > 1')))
Повторяющихся id в датафрейме 0
Повторяющихся постов нет.
Проверим, не попала ли одна и также дата дважды в датафрейм subscribers_general
temp = subscribers_general.groupby(by='date').agg({'subscribers':'count'})
print('Повторяющихся дат в датафрейме', len(temp.query('subscribers > 1')))
Повторяющихся дат в датафрейме 0
Повторяющихся дат нет.
Для удобства работы изменим тип данных в колонке со временем и даты в формат datetime64, сохранив его в колонку date_time и поменяем часовой пояс на Москву, а также добавим колонку с датой потста - post_date
channel_posts['date_time']= pd.to_datetime(channel_posts['date'], format="%Y-%m-%d %H:%M:%S%z", utc=True).dt.tz_convert('Europe/Moscow')
channel_posts['post_date'] = pd.to_datetime(channel_posts['date_time']).dt.date
channel_posts['date_time'].head()
0 2024-01-21 11:02:21+03:00 4 2024-01-20 21:01:00+03:00 5 2024-01-20 19:01:16+03:00 6 2024-01-20 17:20:04+03:00 7 2024-01-20 13:48:48+03:00 Name: date_time, dtype: datetime64[ns, Europe/Moscow]
print('Формат данных в date_time', channel_posts['date_time'].dtype)
print('Формат данных в post_date', channel_posts['post_date'].dtype)
print(f'В датафрейме данные с {channel_posts.post_date.min()} по {channel_posts.post_date.max()}')
Формат данных в date_time datetime64[ns, Europe/Moscow] Формат данных в post_date object В датафрейме данные с 2016-12-19 по 2024-01-21
Тип данных изменен, также мы видим, что в датафрейме посты с 19 декабря 2016 года (даты создания канала) по 21 января 2024 года (даты парсинга). Это подтверждает наше прошлое предположение, почему не у всех постов есть комментарии и реакции - тогда в телеграме еще не было таких функций.
Для удобства работы изменим тип данных в колонке со временем и даты в формат datetime64, сохранив его в колонку comment_time.
comments['comment_time']= pd.to_datetime(comments['date_comment'], format="%Y-%m-%d %H:%M:%S%z", utc=True)
print('Формат данных в comment_time', comments['comment_time'].dtype)
print(f'В датафрейме данные с {pd.to_datetime(comments.comment_time).dt.date.min()} по {pd.to_datetime(comments.comment_time).dt.date.max()}')
Формат данных в comment_time datetime64[ns, UTC] В датафрейме данные с 2022-09-22 по 2024-01-19
Тип данных изменен.
В датафрейме комментарии с 22 сентября 2022 года по 19 января 2024 года.
Для удобства работы изменим тип данных в колонке со временем и даты в формат datetime64, сохранив его в колонку action_date.
print(reposts_and_mentions.date.head(2))
print(reposts_and_mentions.date.tail(2))
0 19 Jan, 10:02 1 18 Jan, 23:33 Name: date, dtype: object 2304 22 Oct 2023, 13:41 2305 22 Oct 2023, 13:08 Name: date, dtype: object
Мы видим, что в датафрейме есть данные от 2023 года, с указанным годом, а также данные 2024 года, без указания года.
#приведем данные в колонке с датами в формат строки
reposts_and_mentions['date_string'] = reposts_and_mentions['date'].astype('str')
#найдем строки с указанным годом
mask = reposts_and_mentions['date_string'].str.contains('2023')
#найдпем первую строку с годом и сохраним ее индекс
date_with_year_index = reposts_and_mentions[mask].head(1).index
#создадим колонку action_date и запишем строки без указания года в формате DateTime6D, подставив 2024 год
reposts_and_mentions['action_date'] = reposts_and_mentions['date_string']
reposts_and_mentions['action_date'].iloc[:date_with_year_index[0]] = pd.to_datetime(reposts_and_mentions.iloc[:date_with_year_index[0], 3],
format='%d %b, %H:%M').dt.strftime('2024-%m-%d %H:%M')
#в колонку action_date запишем строки с годом в формате DateTime64
reposts_and_mentions['action_date'].iloc[date_with_year_index[0]:] = pd.to_datetime(reposts_and_mentions.iloc[date_with_year_index[0]:, 3],
format='%d %b %Y, %H:%M')
#удалим вспомогательную колонку date_string
reposts_and_mentions = reposts_and_mentions.drop(columns=['date_string'])
#приведем все даты к единому формату
reposts_and_mentions['action_date'] = pd.to_datetime(reposts_and_mentions['action_date'], format='%Y-%m-%d %H:%M')
print('Формат данных в action_date', reposts_and_mentions['action_date'].dtype)
print(f'В датафрейме данные с {pd.to_datetime(reposts_and_mentions.action_date).dt.date.min()} \
по {pd.to_datetime(reposts_and_mentions.action_date).dt.date.max()}')
Формат данных в action_date datetime64[ns] В датафрейме данные с 2023-10-22 по 2024-01-19
Форматирование данных прошло успешно.
В датафрейме данные с 22 октября 2023 года по 19 января 2024 года.
Для удобства работы изменим тип данных в колонке с датой в формат даты.
subscribers_general['date_format'] = pd.to_datetime(subscribers_general['date'], format='%d.%m.%y').dt.date
#отсортируем датафрейм по дате
subscribers_general = subscribers_general.sort_values(by='date_format')
print('Формат данных в date', subscribers_general['date_format'].dtype)
print(f'В датафрейме данные с {subscribers_general.date_format.min()} по {subscribers_general.date_format.max()}')
Формат данных в date object В датафрейме данные с 2022-08-17 по 2024-01-18
Изменили формат данных.
В датафрйеме данные с 17 августа 2022 года по 18 января 2024 года.
Для удобства работы объединим колонки с датой и временем в одну, и изменим тип данных в формат datetime64.
print(subscribers_detailed.date.head(2))
print(subscribers_detailed.date.tail(2))
0 Чт, 18 Jan 1 Чт, 18 Jan Name: date, dtype: object 12280 Ср, 17 Aug 2022 12281 Ср, 17 Aug 2022 Name: date, dtype: object
Как и в случае с reposts_and_mentions у данных из 2024 года отсутствует год.
#приведем данные в колонке с датами в формат строки
subscribers_detailed['date_string'] = subscribers_detailed['date'].str.split(n=1).str[1]
#объединим колонки с датой и временем
subscribers_detailed['date_string'] = subscribers_detailed['date_string']+ ' ' + subscribers_detailed['time']
#найдем строки с указанным годом
mask = subscribers_detailed['date_string'].str.contains('2023')
#найдпем первую строку с годом и сохраним ее индекс
date_with_year_index = subscribers_detailed[mask].head(1).index
#создадим колонку date_time и запишем строки без указания года в формате DateTime6D, подставив 2024 год
subscribers_detailed['date_time'] = subscribers_detailed['date_string']
subscribers_detailed['date_time'].iloc[:date_with_year_index[0]] = pd.to_datetime(subscribers_detailed.iloc[:date_with_year_index[0], 4],
format='%d %b %H:%M').dt.strftime('2024-%m-%d %H:%M')
#в колонку date_time запишем строки с годом в формате DateTime64
subscribers_detailed['date_time'].iloc[date_with_year_index[0]:] = pd.to_datetime(subscribers_detailed.iloc[date_with_year_index[0]:, 4],
format='%d %b %Y %H:%M')
#удалим вспомогательную колонку date_string
subscribers_detailed = subscribers_detailed.drop(columns=['date_string'])
#приведем все даты к единому формату
subscribers_detailed['date_time'] = pd.to_datetime(subscribers_detailed['date_time'], format='%Y-%m-%d %H:%M')
print('Формат данных в date', subscribers_detailed['date_time'].dtype)
print(f'В датафрейме данные с {pd.to_datetime(subscribers_detailed.date_time).dt.date.min()}\
по {pd.to_datetime(subscribers_detailed.date_time).dt.date.max()}')
Формат данных в date datetime64[ns] В датафрейме данные с 2022-08-17 по 2024-01-18
Изменили формат данных. В датафрйеме данные с 17 августа 2022 года по 18 января 2024 года.
Вывод
Мы загрузили и подготовили данные к анализу.
Т.к. функция реакций в телеграме появились с 30 декабря 2021 года, то анализировть данные мы будем с этой даты.
analyzes_date = pd.to_datetime('2021-12-30', format='%Y-%m-%d').date()
print(analyzes_date)
2021-12-30
Посмотрим, сколько постов было написано в канале.
print(f'Постов в канале написано за все время - {channel_posts.id.nunique()}')
print(f'Постов в канале написано с {analyzes_date} -', channel_posts.query('post_date > @analyzes_date').id.nunique())
Постов в канале написано за все время - 23326 Постов в канале написано с 2021-12-30 - 7273
Всего в датасете 23326 постов, а с 30 декабря 2021 года было написано 7273 постов.
Визуализируем, как много постов было в нанале на определенную дату.
# посчитаем сколько в каждую дату было написано постов
posts_dimanic = channel_posts.groupby(by='post_date').agg({'id':'count'}).reset_index().sort_values(by='post_date')
# посчитаем кумулятивную сумму, т.е. с накоплением
posts_dimanic['posts_cum'] = posts_dimanic['id'].cumsum()
# посчитаем сколько в каждую дату было написано постов с 30 декаря 2021 года
filter_posts_dimanic = (channel_posts.query('post_date > @analyzes_date')
.groupby(by='post_date')
.agg({'id':'count'})
.reset_index()
.sort_values(by='post_date')
.rename(columns={'id':'posts_per_day'}))
# посчитаем кумулятивную сумму, т.е. с накоплением
filter_posts_dimanic['posts_cum'] = filter_posts_dimanic['posts_per_day'].cumsum()
fig, ax = plt.subplots(1, 2, figsize=(12,6))
fig.suptitle('Количество постов в канале', fontsize=20)
ax[0].plot(posts_dimanic['post_date'], posts_dimanic['posts_cum'])
ax[0].set_title('Количество написанных постов за все время')
ax[0].set(xlabel='Дата', ylabel='Количество написанных постов к дате')
ax[0].tick_params(axis='x', labelrotation=45)
ax[0].grid(True)
ax[1].plot(filter_posts_dimanic['post_date'], filter_posts_dimanic['posts_cum'])
ax[1].set_title(f'Количество написанных постов c {analyzes_date}')
ax[1].set(xlabel='Дата', ylabel='Количество написанных постов к дате')
ax[1].tick_params(axis='x', labelrotation=45)
ax[1].grid(True)
fig.show();
Посмотрим, сколько постов написано в канале в день.
print(filter_posts_dimanic['posts_per_day'].describe(percentiles=[0.05, 0.25, 0.5, 0.75, 0.95]))
filter_posts_dimanic.boxplot(column='posts_per_day', figsize=(8, 4))
plt.title(f'"ящик с усами" по количеству постов в день с {analyzes_date}')
plt.xlabel('')
plt.ylabel('Количество постов в день')
plt.show()
count 746.000000 mean 9.749330 std 5.000419 min 1.000000 5% 4.000000 25% 6.000000 50% 8.000000 75% 12.000000 95% 20.000000 max 37.000000 Name: posts_per_day, dtype: float64
Мы видим, что в канале бывает обычно от 1 до 21 постав в день, в среднем (и медианное значение) около 5 постов в день. Но бывают дни, когда пишутся аномально много постов - до 32. Посмотрим на самые активные дни.
filter_posts_dimanic.query('posts_per_day > 29')
| post_date | posts_per_day | posts_cum | |
|---|---|---|---|
| 82 | 2022-03-28 | 37 | 938 |
| 247 | 2022-09-10 | 33 | 3080 |
| 306 | 2022-11-08 | 30 | 3957 |
Возможные причины увеличения количества постов в день:
2022-03-28 - Оскар.
2022-09-10 - Венецианский кинофестиваль.
2022-11-08 - нет определенного повода, возможно, просто так совпало, что в этот день было много новостей, или это как-то связано с Днем рождения кинопоиска, которое было 7 ноября.
Посмотрим, как менялось количество подписчиков в течение времени.
subscribers_general.head()
| date | subscribers | changes | date_format | |
|---|---|---|---|---|
| 519 | 17.08.22 | 205805 | 62 | 2022-08-17 |
| 518 | 18.08.22 | 205921 | 116 | 2022-08-18 |
| 517 | 19.08.22 | 206163 | 242 | 2022-08-19 |
| 516 | 20.08.22 | 206493 | 330 | 2022-08-20 |
| 515 | 21.08.22 | 206772 | 279 | 2022-08-21 |
plt.figure(figsize=(15, 5))
plt.title('Количество подписчиков канала по времени')
plt.plot(subscribers_general['date_format'], subscribers_general['subscribers'])
plt.xlabel('Дата')
plt.ylabel('Количество подписчиков')
plt.grid(True)
plt.show();
Мы видим, что были периоды резкого подъема в районе августа 2022, февраля 2023 и огромнвый рост после ноября 2023 года, но также мы видим, что в сентябре 2023 года был период, когда количество подписчиков падало.
Посмотрим, как люди подписывались/отписывались.
print(subscribers_general['changes'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))
subscribers_general.boxplot(column='changes', figsize=(8, 4))
plt.title(f'"ящик с усами" по ежедневному изменению подписчиков ')
plt.xlabel('')
plt.ylabel('Количество подписок/отписок')
plt.show()
count 520.000000 mean 664.682692 std 1495.368324 min -1254.000000 1% -472.810000 5% -208.350000 25% 7.250000 50% 153.500000 75% 431.750000 95% 4440.600000 99% 6145.330000 max 9844.000000 Name: changes, dtype: float64
Мы видим, что обычно количество подписчиков в день меняется от приблизительно минус 200, до плюс тысячи.
Нам интересны дни, когда происходило аномальное количество подписок/отписок, посмотрим на 1 и 99 перцентиль (0,01 и 0,99 квантиль).
subscribers_max_limit = subscribers_general['changes'].quantile(0.99)
subscribers_min_limit = subscribers_general['changes'].quantile(0.01)
abnormal_subscriptions = subscribers_general.query('changes <= @subscribers_min_limit | changes >= @subscribers_max_limit')
print('Количество дней с аномальными подписками/отписками - ', len(abnormal_subscriptions))
display(abnormal_subscriptions)
#abnormal_subscriptions.to_csv('/content/drive/My Drive/abnormal_subscriptions.csv', index=False)
Количество дней с аномальными подписками/отписками - 12
| date | subscribers | changes | date_format | |
|---|---|---|---|---|
| 312 | 12.03.23 | 251101 | 9844 | 2023-03-12 |
| 126 | 14.09.23 | 290140 | -475 | 2023-09-14 |
| 97 | 13.10.23 | 289423 | -473 | 2023-10-13 |
| 94 | 16.10.23 | 288171 | -511 | 2023-10-16 |
| 93 | 17.10.23 | 287673 | -498 | 2023-10-17 |
| 63 | 16.11.23 | 322213 | 8303 | 2023-11-16 |
| 62 | 17.11.23 | 331934 | 9721 | 2023-11-17 |
| 57 | 22.11.23 | 358364 | 6163 | 2023-11-22 |
| 55 | 24.11.23 | 371210 | 6776 | 2023-11-24 |
| 36 | 13.12.23 | 440327 | -1254 | 2023-12-13 |
| 35 | 14.12.23 | 439806 | -521 | 2023-12-14 |
| 27 | 22.12.23 | 472752 | 6348 | 2023-12-22 |
abnormal_subscriptions_posts = channel_posts.query('post_date in @abnormal_subscriptions.date_format')
abnormal_subscriptions_posts = abnormal_subscriptions_posts[['id',
'date_time',
'text',
'type_attachment',
'views',
'forwarded',
'reactions_count',
'comments']]
print('Постов в аномальные дни -', len(abnormal_subscriptions_posts))
#abnormal_subscriptions_posts.to_csv('/content/drive/My Drive/abnormal_subscriptions_posts.csv', index=False)
Постов в аномальные дни - 97
Посчитаем вовлеченность для кажого поста, это сделаем по формуле:
(Количество комментариев + колличество реакций + количество репостов) * 100 / количество просмотров.
channel_posts_filtered = channel_posts.query('post_date > @analyzes_date')
channel_posts_filtered['er'] = ((channel_posts_filtered['reactions_count']
+ channel_posts_filtered['comments']
+ channel_posts_filtered['forwarded'])
* 100
/channel_posts_filtered['views'] ).round(2)
print(channel_posts_filtered['er'].describe(percentiles=[0.05, 0.25, 0.5, 0.75, 0.95]))
channel_posts_filtered.boxplot(column='er', figsize=(8, 4))
plt.title(f'"ящик с усами" по вовлеченности (ER) с {analyzes_date}')
plt.xlabel('')
plt.ylabel('Вовлеченность')
plt.show()
count 7273.000000 mean 1.136486 std 0.848679 min 0.080000 5% 0.330000 25% 0.590000 50% 0.920000 75% 1.440000 95% 2.590000 max 13.120000 Name: er, dtype: float64
Мы видим, что обычно "показатель вовлеченности" в среднем около 0,85, медианное значение - 0,92. Аномально большим считается показатель больше 2, но есть рекодсмен - более 12. Посмотрим на посты с самой высокой вовлеченностью, т.е. более 10.
#Сохраним топ 10 постов по вовлеченности
top_10_posts_er = (channel_posts_filtered[['er',
'id',
'date_time',
'text',
'type_attachment',
'views',
'forwarded',
'reactions_count',
'comments']].sort_values(by='er', ascending=False).head(10))
#также сохраним топ10 постов с худшей вовлеченностью
least_10_posts_er = (channel_posts_filtered[['er',
'id',
'date_time',
'text',
'type_attachment',
'views',
'forwarded',
'reactions_count',
'comments']].sort_values(by='er', ascending=False).tail(10))
display(top_10_posts_er.head())
| er | id | date_time | text | type_attachment | views | forwarded | reactions_count | comments | |
|---|---|---|---|---|---|---|---|---|---|
| 8984 | 13.12 | 27739 | 2022-08-22 16:40:57+03:00 | Привет! Этот текст пишет Антон из SMM команды Кинопоиска. Сейчас я уже отдыхаю, все хорошо. Главное, что посты про дарконов выложил. Спасибо за ваше внимание, много приятных комментариев. \n\nЗавтра у меня, конечно же, выходной ❤️ | MessageMediaPhoto | 82651.0 | 3141.0 | 7245 | 460 |
| 1444 | 11.86 | 35634 | 2023-10-29 04:14:56+03:00 | Умер звезда «Друзей» Мэттью Перри. Ему было 54 года.\n\nФото: Getty Images | MessageMediaPhoto | 62826.0 | 2124.0 | 5190 | 136 |
| 4089 | 11.75 | 32799 | 2023-04-26 15:15:34+03:00 | 5 тысяч сердечек на этом посте и мы ставим Кена-Гослинга на аватарку канала 💖 | MessageMediaPhoto | 72894.0 | 383.0 | 8012 | 169 |
| 3797 | 10.90 | 33123 | 2023-05-22 16:22:32+03:00 | Ушла эпоха!\n\n__Будете __[__скучать__](https://t.me/kinopoisk_Industry/1936)__ по привычному дубляжу Леонардо__ __ДиКаприо?\n__❤️__ - да__ \n👎__ - нет__ | MessageMediaPhoto | 70984.0 | 1045.0 | 6529 | 164 |
| 2332 | 8.98 | 34691 | 2023-09-04 12:30:25+03:00 | Кристофер Нолан — лучший режиссер последних 25 лет по версии [Rotten Tomatoes](https://editorial.rottentomatoes.com/article/best-directors-of-the-last-25-years/).\n\n__Согласны?\n\n👍 — да, гений!\n👎 — нет, переоценен\n\n__Фото: Pascal Le Segretain / Getty Images | MessageMediaPhoto | 49100.0 | 146.0 | 4207 | 58 |
На первом месте пост от SMM специалиста команды, после того, как он запустил пуш сообщение по ошибке пользователям приложения.
На втором месте - пост о смерти Мэттью Перри, которая оказалась для всех большой неожиданностью.
На третьем и четвертом месте - посты с призывом ставить реакции.
Также посчитаем суточный ER, он считается, как средний ER в сутки.
er_day = channel_posts_filtered.groupby('post_date', as_index=False).agg({'er':'mean'}).sort_values(by='post_date')
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность подписчиков (ER) по дням')
plt.plot(er_day['post_date'], er_day['er'])
plt.xlabel('Дата')
plt.ylabel('er')
plt.grid(True)
plt.show();
Также посчитаем ER в месяц, как медианное значение ER в месяц.
er_day['month'] = pd.to_datetime(er_day['post_date'], format='%Y-%m-%d').dt.to_period("M")
er_month = er_day.groupby('month', as_index=False).agg({'er':'median'}).sort_values(by='month')
er_month['month'] = er_month['month'].astype('str')
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность подписчиков (ER) по месяцам')
plt.plot(er_month['month'], er_month['er'])
plt.xlabel('Месяц')
plt.tick_params(axis='x', labelrotation=45)
plt.ylabel('er')
plt.grid(True)
plt.show();
Мы видим, что со временем вовлеченность пользователей росла до мая месяца, после чего начала радать, а с июля начала расти до октября, как раз когда выпустили чат бота. Провал в июле можно объяснить тем, что телеграм планировал вводить систему рекомендаций по каналам из-за чего менял органические алгоритмы.
Посчитаем вовлеченность по просмотрам (ERR) по дням, для этого:
(среднее количество просмотров постов в месяц / количество подписчиков) * 100
# на каждую дату посчитаем среднее количество просмотров постов
err_day = channel_posts_filtered.groupby('post_date', as_index=False).agg({'views':'mean'})
# присоединим количество подписчиков на какждый день
err_day = pd.merge(err_day, subscribers_general[['date_format', 'subscribers']], left_on='post_date', right_on='date_format', how='inner')
# посчитаем err
err_day['err'] = (err_day['views']* 100/ err_day['subscribers'] ).round(2)
# удалим лишнюю колонку
err_day = err_day.drop(columns=['date_format'])
# отсортируем таблицу по дате
err_day = err_day.sort_values(by='post_date')
Построим график.
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность (ERR) по просмотрам по дням')
plt.plot(err_day['post_date'], err_day['err'])
plt.xlabel('Дата')
plt.ylabel('ERR')
plt.grid(True)
plt.show();
Мы видим, что ERR всегда колеблется от 15% до 30%, с разовыми сверхпиками.
print(err_day['err'].describe(percentiles=[0.05, 0.25, 0.5, 0.75, 0.99]))
err_day.boxplot(column='err', figsize=(8, 4))
plt.title(f'"ящик с усами" по вовлеченности по просмотрам (ERR) с {analyzes_date}')
plt.xlabel('')
plt.ylabel('ERR')
plt.show()
count 520.000000 mean 21.145865 std 3.928895 min 13.680000 5% 15.999000 25% 18.170000 50% 20.345000 75% 23.772500 99% 30.946700 max 44.390000 Name: err, dtype: float64
Как мы видим, ERR обычно в пределах от 13,68 до 31, средгнее значение по дням - 21,15, медианное - 20,29. Но есть 3 дня, когда ERR была больше 31, до 44,39, посмотрим на эти даты.
err_day.query('err > 30.95')
| post_date | views | subscribers | err | |
|---|---|---|---|---|
| 144 | 2023-01-08 | 73977.500000 | 229026 | 32.30 |
| 178 | 2023-02-11 | 104154.857143 | 234613 | 44.39 |
| 192 | 2023-02-25 | 73560.833333 | 236458 | 31.11 |
| 213 | 2023-03-18 | 78906.250000 | 253514 | 31.13 |
| 311 | 2023-06-24 | 104079.000000 | 269270 | 38.65 |
| 366 | 2023-08-18 | 89854.166667 | 290244 | 30.96 |
err_max_posts = channel_posts_filtered.query('post_date in @err_day.post_date')
err_max_posts = err_max_posts[['er',
'id',
'date_time',
'text',
'type_attachment',
'views',
'forwarded',
'reactions_count',
'comments']]
display(err_max_posts.sort_values(by='er', ascending=False).head(5))
| er | id | date_time | text | type_attachment | views | forwarded | reactions_count | comments | |
|---|---|---|---|---|---|---|---|---|---|
| 8984 | 13.12 | 27739 | 2022-08-22 16:40:57+03:00 | Привет! Этот текст пишет Антон из SMM команды Кинопоиска. Сейчас я уже отдыхаю, все хорошо. Главное, что посты про дарконов выложил. Спасибо за ваше внимание, много приятных комментариев. \n\nЗавтра у меня, конечно же, выходной ❤️ | MessageMediaPhoto | 82651.0 | 3141.0 | 7245 | 460 |
| 1444 | 11.86 | 35634 | 2023-10-29 04:14:56+03:00 | Умер звезда «Друзей» Мэттью Перри. Ему было 54 года.\n\nФото: Getty Images | MessageMediaPhoto | 62826.0 | 2124.0 | 5190 | 136 |
| 4089 | 11.75 | 32799 | 2023-04-26 15:15:34+03:00 | 5 тысяч сердечек на этом посте и мы ставим Кена-Гослинга на аватарку канала 💖 | MessageMediaPhoto | 72894.0 | 383.0 | 8012 | 169 |
| 3797 | 10.90 | 33123 | 2023-05-22 16:22:32+03:00 | Ушла эпоха!\n\n__Будете __[__скучать__](https://t.me/kinopoisk_Industry/1936)__ по привычному дубляжу Леонардо__ __ДиКаприо?\n__❤️__ - да__ \n👎__ - нет__ | MessageMediaPhoto | 70984.0 | 1045.0 | 6529 | 164 |
| 2332 | 8.98 | 34691 | 2023-09-04 12:30:25+03:00 | Кристофер Нолан — лучший режиссер последних 25 лет по версии [Rotten Tomatoes](https://editorial.rottentomatoes.com/article/best-directors-of-the-last-25-years/).\n\n__Согласны?\n\n👍 — да, гений!\n👎 — нет, переоценен\n\n__Фото: Pascal Le Segretain / Getty Images | MessageMediaPhoto | 49100.0 | 146.0 | 4207 | 58 |
Также посчитаем ERR по месяцам, как среднее.
err_day['month'] = pd.to_datetime(err_day['post_date'], format='%Y-%m-%d').dt.to_period("M")
err_month = err_day.groupby('month', as_index=False).agg({'err':'median'}).sort_values(by='month')
err_month['month'] = err_month['month'].astype('str')
plt.figure(figsize=(15, 5))
plt.title('Медианная вовлеченность подписчиков (ERR) по месяцам')
plt.plot(err_month['month'], err_month['err'])
plt.xlabel('Месяц')
plt.tick_params(axis='x', labelrotation=45)
plt.ylabel('er')
plt.grid(True)
plt.show();
На графике мы можем видеть, что среднемесячная вовлеченность (ERR) росла с ноября 2022 по январь 2023 года, после чего, можно сказать, держалась на одном уровне, а после июля 2023 года резко пошла на спад. и Только в октябре 2023 года стала снвоа расти.
Посмотрим, какого размера были посты в канале.
channel_posts_filtered['symbols'] = channel_posts_filtered['text'].apply(lambda x: len(x))
print(channel_posts_filtered['symbols'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))
channel_posts_filtered.boxplot(column='symbols', figsize=(8, 4))
plt.title(f'"ящик с усами" по количеству символов в постах')
plt.xlabel('')
plt.ylabel('количество символов в посте')
plt.show()
count 7273.000000 mean 447.914753 std 413.827414 min 3.000000 1% 29.000000 5% 76.000000 25% 227.000000 50% 349.000000 75% 529.000000 95% 1114.000000 99% 2189.400000 max 4868.000000 Name: symbols, dtype: float64
Мы видим. что обычно в постах от 3 до 1000 сиволов, в среднем 448, медианное значение 349. Очень редкими являются посты более 2189 символов.
Посчитаем, сколько в постах знаков препинания.
channel_posts_filtered['punctuation'] = channel_posts_filtered['text'].apply(lambda x: sum(map(x.count, [',',
'.',
'—',
':',
';',
'-',
'!',
'?'])));
?.:.?— -!:.
print(channel_posts_filtered['punctuation'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))
channel_posts_filtered.boxplot(column='punctuation', figsize=(8, 4))
plt.title(f'"ящик с усами" по количеству знаков перпинания в постах')
plt.xlabel('')
plt.ylabel('количество знаков препинания в посте')
plt.show()
count 7273.000000 mean 15.341812 std 14.880253 min 0.000000 1% 1.000000 5% 2.000000 25% 7.000000 50% 11.000000 75% 18.000000 95% 41.400000 99% 82.000000 max 139.000000 Name: punctuation, dtype: float64
Мы видим, что в постах бывает от 0 до 139 знаков, но также в постах есть ссылки, которые также состоят из знаков перпинания (двоеточий, точек и технических пробелов), что искажает нам анализ.
#Подготовим функцию, которая удалит ссылки в наших постах
def clean_data(df):
df['clean_text'] = df['text'].str.replace('http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', ' ')
#применим функцию
clean_data(channel_posts_filtered)
Посчитаем теперь в очищенных данных количество символов и знаков препинания.
channel_posts_filtered['clear_symbols'] = channel_posts_filtered['clean_text'].apply(lambda x: len(x))
channel_posts_filtered['clear_punctuation'] = channel_posts_filtered['clean_text'].apply(lambda x: sum(map(x.count, [',',
'.',
'—',
':',
';',
'-',
'!',
'?'])))
print(channel_posts_filtered['clear_symbols'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))
channel_posts_filtered.boxplot(column='clear_symbols', figsize=(8, 4))
plt.title(f'"ящик с усами" по количеству символов в постах')
plt.xlabel('')
plt.ylabel('количество символов в посте')
plt.show()
count 7273.000000 mean 363.017324 std 323.224674 min 3.000000 1% 27.720000 5% 59.600000 25% 179.000000 50% 279.000000 75% 443.000000 95% 899.000000 99% 1706.560000 max 3998.000000 Name: clear_symbols, dtype: float64
Мы видим, что обычно в постах бывает от 3 до приблизительно 900 символов, но также встречаются посты до 3998 символов. В среднем в постах 363 символа, медианное значение 279 символа.
print(channel_posts_filtered['clear_punctuation'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))
channel_posts_filtered.boxplot(column='clear_punctuation', figsize=(8, 4))
plt.title(f'"ящик с усами" по количеству знаков перпинания в постах')
plt.xlabel('')
plt.ylabel('количество знаков препинания в посте')
plt.show()
count 7273.000000 mean 9.348824 std 8.780056 min 0.000000 1% 0.000000 5% 1.000000 25% 4.000000 50% 7.000000 75% 11.000000 95% 24.000000 99% 47.280000 max 116.000000 Name: clear_punctuation, dtype: float64
Также мы видим, что в постах обычно бывает от 0 до 21 знаков препинания, но есть посты и до 120 знаков препинания. Посмотрим на этот пост, может быть это кая-то ошибка.
display(channel_posts_filtered.query('clear_punctuation > 100')['text'])
13037 Саша Амато, [Golden Chihuahua](https://t.me/goldchihuahua)\n\nЯ успел посмотреть три серии и могу точно сказать, что главная проблема сериала в том, что его создатели страдают от странной слепоты к той культуре, которая породила Анну Делви. Когда я читал оригинальную статью, это была в первую очередь история про белого привилегированного человека, обладающего якобы безупречной биографией и произошедшего из очень богатой семьи. Культура и общество всегда поддерживали таких девушек. Будучи белой, очень легко втереться в доверие и попасть в высшее общество Нью-Йорка, во все арт- и фешен-тусовки (вряд ли Делви удалось бы это сделать, если бы она была, например, афроамериканкой или мексиканкой). Преступница Делви, которая манипулирует людьми и обманывает их, и есть продукт этого лицемерного общества, погрязшего в стереотипах. Тут показателен недавний скандал с «И просто так», где в одном из эпизодов звучит фраза: «Русская проститутка — обычное дело в дорогой недвижимости». Мне хотелось бы увидеть именно критический сериал, которые бы глубже смог изучить тему привилегированности. Было бы намного интереснее, если бы сериал был посвящен не столько Анне, сколько обществу, которое ей позволило стать такой успешной аферисткой.\n\nЮлия Пош, «[Антиглянец](https://t.me/sncmag)»\n\nМы сами смотрим сериал от Netflix про нью-йоркскую мошенницу Анну Дельви и вам советуем. Первая серия чуть тяжеловесна, но дальше просто шик как снято. Костюмы, актеры, локации, обманы — не оторваться. И главная актриса подобрана отлично. Из весьма скромных 300 000 долларов, заплаченных за историю Netflix, реальная Анна Сорокина-Делви сейчас понемногу возвращает долг своим невольным кредиторам.\n\n«[Синька](https://t.me/thynk)»\n\nNetflix начал заметную кампанию против популярных мошенников: несколько недель назад вышел «Аферист из Tinder», а теперь — история аферистки из Нью-Йорка, которая явно стоит своего внимания и отлично учит нас всех хорошо лгать и продумывать все свои легенды заранее, чтобы не превратиться из Анны Делви в Анну Сорокину, как в этом сериале.\n\n«[Луис Иванович Вьютон](https://t.me/LIVuiton)»\n\nОх, если бы в сериале снялась сама Анна, я бы смотрел эту работу куда более охотно, но Джулия играла Сорокину немного не так, потому что повадки Рут из «Озарка» все-таки сохранились, да и бойкую журналистку Вивьен Кент (Анна Кламски) тут показывают куда чаще, чем главную героиню. Долго, нудно и явно не так, как распиарили. Мне кажется, Анна и тут «обманула» Netflix.\n\n«[Алло, Галочка, ты не поверишь](https://t.me/Hello_Galochka)!»\n\n«Все дело в деталях. А эта сука была безупречна!» — говорит один из героев сериала про Анну Делви, которая Сорокина. И он чертовски прав. Один из режиссеров «Изобретая Анну» — Дэвид Фрэнкел («Дьявол носит Prada»). Вот уж кто точно знает толк в деталях. Несмотря на то, что реальная история довольно драматичная, сериал получился динамичным, стильным и местами даже смешным. Авторам невнятной «Эмили в Париже» того же Netflix есть чему поучиться. Отдельный привет не только актрисе Джулии Гарнер, к героине которой во время просмотра испытываешь самые разные чувства — от ненависти до восхищения, — но и, собственно, самой Анне; суд обязал ее выплатить в общей сложности 123 000 долларов, а за свою историю она получила от Netflix 320 000. Снова профит!\n\nКатя Федорова, [Good morning, Karl](https://t.me/goodmorningkarl)!\n\nИстория Анны Делви — это что-то из серии «нарочно не придумаешь». Я была заворожена ей еще с момента выхода той самой статьи, которая легла в основу сериала, и очень его ждала. «Изобретая Анну» получился очень динамичным и зрелищным. Правда, поначалу мне казалось, что за всеми сценами роскошной жизни как будто потерялся ответ на вопрос, что же такого в Анне, которая смогла развести на огромные деньги финансовых воротил Нью-Йорка, но последние две серии поразили меня настолько, что я уже готова сама устроить краудфандинг на ее новые проекты. Добавьте к этому роскошные наряды, блестящую игру актеров (подружки Анны — мои фаворитки) и порой гомерически смешные диалоги. В общем, смотрите обязательно! Name: text, dtype: object
Мы видим, что это пост - объединенные рецензии нескольких человек.
Посмотрим, каких постов в канале больше, поделим их на 4 категории:
#подготовим функцию
def post_volume(volume):
try:
if volume < 50:
return 'очень короткий пост'
elif 50 <= volume < 500:
return 'короткий пост'
elif 500 <= volume < 1000:
return 'обычный пост'
else:
return 'длинный пост'
except:
pass
Применим функцию
channel_posts_filtered['post_cat'] = channel_posts_filtered['clear_symbols'].apply(post_volume)
display(channel_posts_filtered.groupby(by='post_cat', as_index=False)['id'].count())
channel_posts_filtered['post_cat'].value_counts().plot(kind="pie",
autopct='%1.1f%%',
explode=[0.05, 0.01, 0.01, 0.01],
legend=True,
title='Доля постов по категориям',
ylabel='',
labeldistance=None,
figsize=(6, 6),
cmap='Pastel2').legend(bbox_to_anchor=(1.5, 1));
| post_cat | id | |
|---|---|---|
| 0 | длинный пост | 248 |
| 1 | короткий пост | 5506 |
| 2 | обычный пост | 1229 |
| 3 | очень короткий пост | 290 |
75% постов до 50 символов, вероятно, это анонсы, постеры, кадры из фильмов. 17% - посты обычной длины, 4% очень коротких постов и 3,4% очень длинных постов. Посмотрим, как распределяются медианный ER по категриям.
channel_posts_filtered.groupby(by='post_cat')['er'].median().sort_values(ascending=False).plot(kind='bar',
figsize=(12,5),
title='Медианный ER по категориям постов',
xlabel='Категория поста',
ylabel='Медианный ER',
rot=0
);
channel_posts_filtered.groupby(by='post_cat')['views'].median().sort_values(ascending=False).plot(kind='bar',
figsize=(12,5),
title='Медианное количество просмотров по категориям постов',
xlabel='Категория поста',
ylabel='Медианное количество просмотров',
rot=0
);
fig, axes = plt.subplots(nrows=4, figsize = (10, 15))
fig.tight_layout(pad=5.0)
# ER
ax1 = sns.boxplot(x='er', y='post_cat', data=channel_posts_filtered, color='#82E0AA', ax=axes[0])
ax1.set(title = 'ER по категориям постов', ylabel = 'Категория', xlabel = 'ER')
# Просмотры
ax2 = sns.boxplot(x='views', y='post_cat', data=channel_posts_filtered, color='#85C1E9', ax=axes[1])
ax2.set(title = 'Просмотов по категориям постов', ylabel = 'Категория', xlabel = 'Количество просмотров')
# Репосты
ax3 = sns.boxplot(x='forwarded', y='post_cat', data=channel_posts_filtered, color='#F5B7B1', ax=axes[2])
ax3.set(title = 'Репостов по категориям постов', ylabel = 'Категория', xlabel = 'Количество репостов')
# Комментарии
ax4 = sns.boxplot(x='comments', y='post_cat', data=channel_posts_filtered, color='#FAD7A0', ax=axes[3])
ax4.set(title = 'Комментариев по категориям постов', ylabel = 'Категория', xlabel = 'Количество комментариев')
plt.show()
Мы видим, что у коротких постов выше всего ER, количество просмотров и репостов. Но разница по просмотрам у категорий не очень большая. А вот ER у больших постов значительно ниже, чем у других категорий, возможно, пользователи ленятся читать большие тексты и поэтому и не взаимодействуют с ними.
Посмотрим, какие слова чаще употребляются в постах, в зависимости от их ER - выше и ниже медианного значения.
Сохраним текст в разные переменные.
min_er_text = ' '.join(channel_posts_filtered.query('er <= @channel_posts_filtered.er.median()')['clean_text'])
max_er_text = ' '.join(channel_posts_filtered.query('er > @channel_posts_filtered.er.median()')['clean_text'])
# добавляем к стандартным знакам пунктуации кавычки и многоточие
spec_chars = string.punctuation + '«»\t—…’'
# делаем все слова с маленькой буквы
min_er_text = min_er_text.lower()
max_er_text = max_er_text.lower()
# очищаем текст от знаков препинания
min_er_text = "".join([ch for ch in min_er_text if ch not in spec_chars])
max_er_text = "".join([ch for ch in max_er_text if ch not in spec_chars])
# меняем переносы строк на пробелы
min_er_text = re.sub('\n', ' ', min_er_text)
max_er_text = re.sub('\n', ' ', max_er_text)
# убираем из текста цифры
min_er_text = "".join([ch for ch in min_er_text if ch not in string.digits])
max_er_text = "".join([ch for ch in max_er_text if ch not in string.digits])
# токенизируем текст
min_er_text_tokens = word_tokenize(min_er_text)
max_er_text_tokens = word_tokenize(max_er_text)
# переводим токены в текстовый формат
min_er_text = nltk.Text(min_er_text_tokens)
max_er_text = nltk.Text(max_er_text_tokens)
# и считаем слова в тексте по популярности
min_er_text_fdist = FreqDist(min_er_text)
max_er_text_fdist = FreqDist(max_er_text)
# выводим первые 5 популярных слов
print(min_er_text_fdist.most_common(5))
print(max_er_text_fdist.most_common(5))
[('в', 7641), ('и', 7410), ('на', 3630), ('с', 2339), ('о', 1922)]
[('в', 6279), ('и', 5348), ('на', 3384), ('с', 1701), ('не', 1220)]
В самых популярных словах у нас предлоги. Необходимо очистить текст от них.
# добавляем русские и английские стоп-слова
russian_stopwords = stopwords.words("russian")
russian_stopwords += stopwords.words("english")
# перестраиваем токены, не учитывая стоп-слова
min_er_text_tokens = [token.strip() for token in min_er_text_tokens if token not in russian_stopwords]
max_er_text_tokens = [token.strip() for token in max_er_text_tokens if token not in russian_stopwords]
# снова приводим токены к текстовому виду
min_er_text = nltk.Text(min_er_text_tokens)
max_er_text = nltk.Text(max_er_text_tokens)
# считаем заново частоту слов
min_er_text_fdist = FreqDist(min_er_text)
max_er_text_fdist = FreqDist(max_er_text)
# показываем самые популярные
print(min_er_text_fdist.most_common(10))
print(max_er_text_fdist.most_common(10))
[('фильм', 783), ('это', 605), ('кинопоиск', 532), ('который', 521), ('кино', 520), ('фильма', 483), ('сериала', 469), ('рассказываем', 450), ('года', 438), ('сериал', 428)]
[('фильм', 760), ('это', 586), ('фото', 570), ('фильма', 528), ('года', 517), ('кинопоиск', 515), ('подписывайтесь', 473), ('сериала', 404), ('премьера', 400), ('🔥', 363)]
мы видим, что в тексте часто встречается слово "это", добавим его в стоп слова.
# добавляем свои слова в этот список
russian_stopwords.extend(['это'])
# перестраиваем токены, не учитывая стоп-слова
min_er_text_tokens = [token.strip() for token in min_er_text_tokens if token not in russian_stopwords]
max_er_text_tokens = [token.strip() for token in max_er_text_tokens if token not in russian_stopwords]
# снова приводим токены к текстовому виду
min_er_text = nltk.Text(min_er_text_tokens)
max_er_text = nltk.Text(max_er_text_tokens)
# считаем заново частоту слов
min_er_text_fdist = FreqDist(min_er_text)
max_er_text_fdist = FreqDist(max_er_text)
# показываем самые популярные
print(min_er_text_fdist.most_common(10))
print(max_er_text_fdist.most_common(10))
[('фильм', 783), ('кинопоиск', 532), ('который', 521), ('кино', 520), ('фильма', 483), ('сериала', 469), ('рассказываем', 450), ('года', 438), ('сериал', 428), ('подписывайтесь', 385)]
[('фильм', 760), ('фото', 570), ('фильма', 528), ('года', 517), ('кинопоиск', 515), ('подписывайтесь', 473), ('сериала', 404), ('премьера', 400), ('🔥', 363), ('лет', 357)]
Сделаем облако слов.
# переводим всё в текстовый формат
min_er_text_raw = " ".join(min_er_text)
max_er_text_raw = " ".join(max_er_text)
# готовим размер картинки
wordcloud = WordCloud(width=1600, height=800).generate(min_er_text_raw)
plt.figure( figsize=(20,10), facecolor='k')
# добавляем туда облако слов
plt.imshow(wordcloud)
# выключаем оси и подписи
plt.axis("off")
# убираем рамку вокруг
plt.tight_layout(pad=0)
# выводим картинку на экран
plt.show()
# готовим размер картинки
wordcloud = WordCloud(width=1600, height=800).generate(max_er_text_raw)
plt.figure( figsize=(20,10), facecolor='k')
# добавляем туда облако слов
plt.imshow(wordcloud)
# выключаем оси и подписи
plt.axis("off")
# убираем рамку вокруг
plt.tight_layout(pad=0)
# выводим картинку на экран
plt.show()
Мы видим, что много повторяющихся слов, которые нам надо очистить еще, а именно привести их к нормальной форме. Нормальная форма слова — это то, как оно записано в словаре:
# добавляем анализатор слов
morph = pymorphy2.MorphAnalyzer()
# тут будут те же самые слова, что и в исходном тексте, но в нормальной форме
min_er_filtered_tokens = []
max_er_filtered_tokens = []
# перебираем все слова в исходном тексте
#для er<= median
for token in min_er_text_tokens:
# получаем нормальную форму текущего слова
p = morph.parse(str(token))[0]
# добавляем его в новый массив
min_er_filtered_tokens.append(p.normal_form)
#для er> median
for token in max_er_text_tokens:
# получаем нормальную форму текущего слова
p = morph.parse(str(token))[0]
# добавляем его в новый массив
max_er_filtered_tokens.append(p.normal_form)
# переводим токены в текстовый формат
min_er_text = nltk.Text(min_er_filtered_tokens)
max_er_text = nltk.Text(max_er_filtered_tokens)
# считаем заново частоту слов
min_er_text_fdist = FreqDist(min_er_text)
max_er_text_fdist = FreqDist(max_er_text)
# показываем самые популярные
print(min_er_text_fdist.most_common(10))
print(max_er_text_fdist.most_common(10))
[('фильм', 2235), ('который', 1627), ('сериал', 1425), ('кинопоиск', 1253), ('новый', 1238), ('год', 1119), ('наш', 991), ('главный', 845), ('подкаст', 741), ('режиссёр', 723)]
[('фильм', 2163), ('год', 1352), ('который', 1146), ('сериал', 1093), ('новый', 1051), ('кинопоиск', 917), ('выйти', 642), ('первый', 636), ('роль', 630), ('наш', 611)]
Мы видим, что есть много слов, котоыре встречаются и не несут для нас никакой пользы: кинопоиск, который, год
# добавляем свои слова в этот список
russian_stopwords.extend(['это', 'кинопоиск', 'который', 'год'])
# перестраиваем токены, не учитывая стоп-слова
min_er_filtered_tokens = [token.strip() for token in min_er_filtered_tokens if token not in russian_stopwords]
max_er_filtered_tokens = [token.strip() for token in max_er_filtered_tokens if token not in russian_stopwords]
# снова приводим токены к текстовому виду
min_er_text = nltk.Text(min_er_filtered_tokens)
max_er_text = nltk.Text(max_er_filtered_tokens)
# считаем заново частоту слов
min_er_text_fdist = FreqDist(min_er_text)
max_er_text_fdist = FreqDist(max_er_text)
# показываем самые популярные
print(min_er_text_fdist.most_common(10))
print(max_er_text_fdist.most_common(10))
[('фильм', 2235), ('сериал', 1425), ('новый', 1238), ('наш', 991), ('главный', 845), ('подкаст', 741), ('режиссёр', 723), ('рассказывать', 691), ('первый', 617), ('роль', 614)]
[('фильм', 2163), ('сериал', 1093), ('новый', 1051), ('выйти', 642), ('первый', 636), ('роль', 630), ('наш', 611), ('фото', 570), ('премьера', 569), ('главный', 557)]
Также мы видим, что фильм, сериал, новый встречаются часто в обоих списках, очистим и от них.
# добавляем свои слова в этот список
russian_stopwords.extend(['фильм', 'сериал', 'новый'])
# перестраиваем токены, не учитывая стоп-слова
min_er_filtered_tokens = [token.strip() for token in min_er_filtered_tokens if token not in russian_stopwords]
max_er_filtered_tokens = [token.strip() for token in max_er_filtered_tokens if token not in russian_stopwords]
# снова приводим токены к текстовому виду
min_er_text = nltk.Text(min_er_filtered_tokens)
max_er_text = nltk.Text(max_er_filtered_tokens)
# считаем заново частоту слов
min_er_text_fdist = FreqDist(min_er_text)
max_er_text_fdist = FreqDist(max_er_text)
# показываем самые популярные
print(min_er_text_fdist.most_common(10))
print(max_er_text_fdist.most_common(10))
[('наш', 991), ('главный', 845), ('подкаст', 741), ('режиссёр', 723), ('рассказывать', 691), ('первый', 617), ('роль', 614), ('выйти', 597), ('хороший', 553), ('сезон', 538)]
[('выйти', 642), ('первый', 636), ('роль', 630), ('наш', 611), ('фото', 570), ('премьера', 569), ('главный', 557), ('сезон', 526), ('режиссёр', 503), ('актёр', 488)]
Готовим текст для облака слов.
min_er_text_raw = " ".join(min_er_text)
max_er_text_raw = " ".join(max_er_text)
Облако слов для постов с ER меньше или равным медианного значения
# готовим размер картинки
wordcloud = WordCloud(width=1600, height=800).generate(min_er_text_raw)
plt.figure( figsize=(20,10), facecolor='k')
# добавляем туда облако слов
plt.imshow(wordcloud)
# выключаем оси и подписи
plt.axis("off")
# убираем рамку вокруг
plt.tight_layout(pad=0)
# выводим картинку на экран
plt.show()
Облако слов для постов с ER больше медианного значения
# готовим размер картинки
wordcloud = WordCloud(width=1600, height=800).generate(max_er_text_raw)
plt.figure( figsize=(20,10), facecolor='k')
# добавляем туда облако слов
plt.imshow(wordcloud)
# выключаем оси и подписи
plt.axis("off")
# убираем рамку вокруг
plt.tight_layout(pad=0)
# выводим картинку на экран
plt.show()
Построим облака слов для существительных, прилагательных и глаголов.
# новые переменные для существительных, прилагательных и глаголов
min_er_noun_tokens = []
min_er_adjf_tokens = []
min_er_verb_tokens = []
# перебираем все слова в исходном тексте
for token in min_er_filtered_tokens:
# получаем нормальную форму текущего слова
p = morph.parse(str(token))[0]
if "NOUN" in p.tag:
# добавляем его в массив c существительными
min_er_noun_tokens.append(p.normal_form)
elif "ADJF" in p.tag or "ADJS" in p.tag:
# добавляем его в массив c прилагательными
min_er_adjf_tokens.append(p.normal_form)
elif "VERB" in p.tag or "INFN" in p.tag:
# добавляем его в массив c глаголами
min_er_verb_tokens.append(p.normal_form)
# новые переменные для существительных, прилагательных и глаголов
max_er_noun_tokens = []
max_er_adjf_tokens = []
max_er_verb_tokens = []
# перебираем все слова в исходном тексте
for token in max_er_filtered_tokens:
# получаем нормальную форму текущего слова
p = morph.parse(str(token))[0]
if "NOUN" in p.tag:
# добавляем его в массив c существительными
max_er_noun_tokens.append(p.normal_form)
elif "ADJF" in p.tag or "ADJS" in p.tag:
# добавляем его в массив c прилагательными
max_er_adjf_tokens.append(p.normal_form)
elif "VERB" in p.tag or "INFN" in p.tag:
# добавляем его в массив c глаголами
max_er_verb_tokens.append(p.normal_form)
Чтобы не повторять код с построением облака подготовим функцию.
def words_cloud(tokens):
text = nltk.Text(tokens)
text_raw = " ".join(text)
# готовим размер картинки
wordcloud = WordCloud(width=1600, height=800).generate(text_raw)
plt.figure(figsize=(20,10), facecolor='k')
# добавляем туда облако слов
plt.imshow(wordcloud)
# выключаем оси и подписи
plt.axis("off")
# убираем рамку вокруг
plt.tight_layout(pad=0)
# выводим картинку на экран
plt.show()
И посмотрим на каждые части речи отдельно.
ER <= median
words_cloud(min_er_noun_tokens)
ER > median
words_cloud(max_er_noun_tokens)
Набор слов очень похож, но как будто пользователи хуже взаимоействуют с постами о прокате фильмов.
ER <= median
words_cloud(min_er_adjf_tokens)
ER > median
words_cloud(max_er_adjf_tokens)
По прилагательным ситуация также похожа, кажется, что пользователям меньше интересна информация о российских фильмах.
ER <= median
words_cloud(min_er_verb_tokens)
ER > median
words_cloud(max_er_verb_tokens)
По глаголам есть разница, что у постов с большим ER есть слово "появиться", что может говорить нам о том, что пользователям интересна информация о новинках.
Наборы слов очень похоиж, но как будто пользователи хуже взаимодействуют с постами о прокате фильмо и о отечественных картинах, но больше интересна информация о новинках.
Для дальнейшего удобства упакуем получение токенов в функцию.
def get_word_tokens(text):
"""
Функция для получения токенов существительных, прилагательных и глаголов текста,
для последующего построения облака слов
Args:
text(str): текст, токены котоорого мы хотим получить
Returns:
text_noun_tokens(list): токены существительных
text_adjf_tokens: токены прилагательных
text_verb_tokens: токены глаголов
"""
# делаем все слова с маленькой буквы
text = text.lower()
# очищаем текст от знаков препинания
text = "".join([ch for ch in text if ch not in spec_chars])
# меняем переносы строк на пробелы
text = re.sub('\n', ' ', text)
# убираем из текста цифры
text = "".join([ch for ch in text if ch not in string.digits])
# токенизируем текст
text_tokens = word_tokenize(text)
#фильтруем от стоп-слов
text_tokens = [token.strip() for token in text_tokens if token not in russian_stopwords]
# добавляем анализатор слов
morph = pymorphy2.MorphAnalyzer()
# тут будут те же самые слова, что и в исходном тексте, но в нормальной форме
text_filtered_tokens = []
# перебираем все слова в исходном тексте
for token in text_tokens:
# получаем нормальную форму текущего слова
p = morph.parse(str(token))[0]
# добавляем его в новый массив
text_filtered_tokens.append(p.normal_form)
# новые переменные для существительных, прилагательных и глаголов
text_noun_tokens = []
text_adjf_tokens = []
text_verb_tokens = []
# перебираем все слова в исходном тексте
for token in text_filtered_tokens:
# получаем нормальную форму текущего слова
p = morph.parse(str(token))[0]
if "NOUN" in p.tag:
# добавляем его в массив c существительными
text_noun_tokens.append(p.normal_form)
elif "ADJF" in p.tag or "ADJS" in p.tag:
# добавляем его в массив c прилагательными
text_adjf_tokens.append(p.normal_form)
elif "VERB" in p.tag or "INFN" in p.tag:
# добавляем его в массив c глаголами
text_verb_tokens.append(p.normal_form)
return text_noun_tokens, text_adjf_tokens, text_verb_tokens
Посмотрим, какие комментарии пишут пользователи под постами с разной ER.
Для определения, тона комментариев (положительный, негативный или нейтральный) мы будем использовать библиотеки dostoevsky и FastTextSocialNetworkModel, к сожалению запустить их через блокнот не получилось, поэтому прилагаем скрипт app.py, с помощью которого выполнен анализ.
Загружаем полученный датафрейм.
# подготавливаем ссылки для скачивания файлов с Яндекс.Диска.
# публичная ссылка на датасеты на яндекс диске
folder_url = 'https://disk.yandex.ru/d/nflbLG3sCHE-sg'
# названия файлов
file_url = ['comments_with_sentiment.csv']
# загружаем каждый файл в свой датафрейм
url = ('https://cloud-api.yandex.net/v1/disk/public/resources/download'
+ '?public_key='
+ urllib.parse.quote(folder_url)
+ '&path=/'
+ urllib.parse.quote(file_url[0]))
# запрос ссылки на скачивание
r = requests.get(url)
# 'парсинг' ссылки на скачивание
h = json.loads(r.text)['href']
comments_sentiment = pd.read_csv(h, index_col=[0])
comments_sentiment = comments_sentiment.reset_index()
print(comments_sentiment.info())
display(comments_sentiment.sample(5))
<class 'pandas.core.frame.DataFrame'> RangeIndex: 138902 entries, 0 to 138901 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 post 132175 non-null object 1 id 138902 non-null int64 2 neutral 138902 non-null float64 3 negative 138902 non-null float64 4 positive 138902 non-null float64 dtypes: float64(3), int64(1), object(1) memory usage: 5.3+ MB None
| post | id | neutral | negative | positive | |
|---|---|---|---|---|---|
| 15147 | Не ссылки не чего | 36247 | 0.999457 | 0.095359 | 0.000000 |
| 113234 | Неделю | 31060 | 0.995255 | 0.001937 | 0.000000 |
| 35955 | Через год будет сентябрь. Это повтор? Это стагнация? Это раздражает? | 34811 | 0.952584 | 0.067557 | 0.000000 |
| 4831 | Чувак хватит спорить пока не доказано не калышит,что сказано всё просто | 36866 | 0.000000 | 0.637041 | 0.182436 |
| 53054 | ну вот мне и интересно как там детям взрослые этот момент объясняют | 33762 | 0.847978 | 0.000000 | 0.191943 |
В датафрейме 5 колонок и 138902 строк.
post - текст комментария,id - id поста к которому был сделан комментарий,neutral - уровень нейтральнсти поста от 0 до 1, где 1 точно нейтральный, а 0 точно не нейтральный,negative - уровень негативности поста от 0 до 1, где 1 точно негативный, а 0 точно не негативный,positive - уровень позитивности поста от 0 до 1, где 1 точно позитивный, а 0 точно не позитивный.plt.figure( figsize=(10,7))
comments_sentiment[['neutral', 'negative', 'positive']].boxplot()
plt.xlabel('Показатель')
plt.ylabel('Уровень')
plt.title('Уровень эмоциональной окраски комментариев')
plt.grid(alpha=1)
plt.show();
print('Уровень нейтральности комментариев')
print(comments_sentiment['neutral'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))
print()
print('Уровень негативности комментариев')
print(comments_sentiment['negative'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))
print()
print('Уровень позитивности комментариев')
print(comments_sentiment['positive'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))
Уровень нейтральности комментариев count 138902.000000 mean 0.620568 std 0.358339 min 0.000000 1% 0.000000 5% 0.000000 25% 0.327678 50% 0.718604 75% 0.968866 95% 1.000010 99% 1.000010 max 1.000010 Name: neutral, dtype: float64 Уровень негативности комментариев count 138902.000000 mean 0.097521 std 0.179495 min 0.000000 1% 0.000000 5% 0.000000 25% 0.000000 50% 0.000000 75% 0.144159 95% 0.484390 99% 0.822199 max 1.000010 Name: negative, dtype: float64 Уровень позитивности комментариев count 138902.000000 mean 0.108561 std 0.235973 min 0.000000 1% 0.000000 5% 0.000000 25% 0.000000 50% 0.000000 75% 0.069552 95% 0.743178 99% 0.996065 max 1.000010 Name: positive, dtype: float64
Обычно, уровень негативности в комментариях от 0 до 0,35, в среднем 0.09, а медианное значение 0, т.е. половина комментариев точно не негативная.
Обычно, уровень позитива в комментариях от 0 до 0,19, в среднем 0.1, а медианное значение 0, т.е. половина комментариев точно не позитивные.
Обычно, уровень нейтральности в постах от 0 до 1, в среднем 0.62, а медианное значение 0,71, т.е. большая часть комментариев имеет нейтральный тон.
Выглядит так, что пользователи чаще всего отавляют нейтральные комментарие, а позитивные реже всего.
Посмотрим, разный ли уровень эмоциональной окраски у коментариев у постов с разным ER.
er_min_ids = channel_posts_filtered.query('er <= @channel_posts_filtered.er.median()')['id']
er_max_ids = channel_posts_filtered.query('er > @channel_posts_filtered.er.median()')['id']
fig, axes = plt.subplots(ncols=2, figsize = (10, 6))
# ER <= median
ax1 = sns.boxplot(data=comments_sentiment.query('id in @er_min_ids')[['neutral', 'negative', 'positive']],
color='#82E0AA',
ax=axes[0])
ax1.set(title = """Уровень эмоциональной окраски
комментариев у постов
с ER меньше или раным медианного значения""", ylabel = 'Уровень', xlabel = 'Показатель')
ax1.yaxis.grid(True)
# ER > median
ax2 = sns.boxplot(data=comments_sentiment.query('id in @er_max_ids')[['neutral', 'negative', 'positive']],
color='#85C1E9',
ax=axes[1])
ax2.set(title = """Уровень эмоциональной окраски
комментариев у постов
с ER больше медианного значения""", ylabel = 'Уровень', xlabel = 'Показатель')
ax2.yaxis.grid(True)
plt.show()
Выглядит так, как будто у постов с лучшей вовлеченностью уровень позитивности комментариев выше, посмотрим на цифры.
print('Уровень позитивности комментариев у постов с ER меньше или равным медианного згначения')
print(comments_sentiment
.query('id in @er_min_ids')['positive']
.describe(percentiles=[0.5, 0.75, 0.95, 0.99]))
print()
print('Уровень позитивности комментариев у постов с ER больше медианного згначения')
print(comments_sentiment
.query('id in @er_max_ids')['positive']
.describe(percentiles=[0.5, 0.75, 0.95, 0.99]))
Уровень позитивности комментариев у постов с ER меньше или равным медианного згначения count 34019.000000 mean 0.102544 std 0.227765 min 0.000000 50% 0.000000 75% 0.055015 95% 0.699264 99% 0.992848 max 1.000010 Name: positive, dtype: float64 Уровень позитивности комментариев у постов с ER больше медианного згначения count 104883.000000 mean 0.110513 std 0.238542 min 0.000000 50% 0.000000 75% 0.073706 95% 0.754925 99% 0.996633 max 1.000010 Name: positive, dtype: float64
Можно сказать, что у постов с высокой вовлеченностью комментарии немного позитивнее, чем у постов с меньшей вовлеченностью.
Облака слов оказались почти идентичными, а выполнение кода занимает много времени и ресурсов, поэтому сейчас код закомментирован. При необходимости его можно раскомментировать и выполнить.
Посмотрим на облако слов коментариев
Обновим стоп-слова
#russian_stopwords = stopwords.words("russian")
#russian_stopwords += stopwords.words("english")
Подготовим текст.
#comments_sentiment['post'] = comments_sentiment['post'].astype('str')
#er_min_comments = ' '.join(comments_sentiment.query('id in @er_min_ids')['post'])
#er_max_comments = ' '.join(comments_sentiment.query('id in @er_max_ids')['post'])
Применим подготовленную ранее функцию для получения токенов.
#er_min_com_noun_tokens, er_min_com_adjf_tokens, er_min_com_verb_tokens = get_word_tokens(er_min_comments)
#er_max_com_noun_tokens, er_max_com_adjf_tokens, er_max_com_verb_tokens = get_word_tokens(er_max_comments)
ER меньше или равен медианному значению
#words_cloud(er_min_com_noun_tokens)
ER больше медианного значения
#words_cloud(er_max_com_noun_tokens)
ER меньше или равен медианному значению
#words_cloud(er_min_com_adjf_tokens)
ER больше медианного значения
#words_cloud(er_max_com_adjf_tokens)
ER меньше или равен медианному значению
#words_cloud(er_min_com_verb_tokens)
ER больше медианного значения
#words_cloud(er_max_com_verb_tokens)
Разницы в словах у постов с разной вовлеченностью, можно сказать, нет.
Посмотрим, есть ли у нас корреляция между показателями постов.
pd.plotting.scatter_matrix(channel_posts_filtered[['er',
'clear_symbols',
'clear_punctuation',
'forwarded',
'reactions_count',
'views',
'comments']], figsize=(9, 9));
display(pd.DataFrame(channel_posts_filtered[['er',
'clear_symbols',
'clear_punctuation',
'forwarded',
'reactions_count',
'views',
'comments']].corr()).style.background_gradient('coolwarm'))
| er | clear_symbols | clear_punctuation | forwarded | reactions_count | views | comments | |
|---|---|---|---|---|---|---|---|
| er | 1.000000 | -0.124445 | -0.072238 | 0.630640 | 0.857685 | 0.096318 | 0.311890 |
| clear_symbols | -0.124445 | 1.000000 | 0.922682 | -0.061784 | -0.098189 | -0.023614 | -0.090965 |
| clear_punctuation | -0.072238 | 0.922682 | 1.000000 | -0.032708 | -0.057557 | -0.030044 | -0.065729 |
| forwarded | 0.630640 | -0.061784 | -0.032708 | 1.000000 | 0.477325 | 0.382870 | 0.214702 |
| reactions_count | 0.857685 | -0.098189 | -0.057557 | 0.477325 | 1.000000 | 0.354819 | 0.284498 |
| views | 0.096318 | -0.023614 | -0.030044 | 0.382870 | 0.354819 | 1.000000 | 0.264329 |
| comments | 0.311890 | -0.090965 | -0.065729 | 0.214702 | 0.284498 | 0.264329 | 1.000000 |
Т.к. Вовлеченность рассчитывается, как (Количество комментариев + колличество реакций + количество репостов) * 100 / количество просмотров, то корреляция с ними логична. И мы видим, что количество символов или количество знаков препинания практически не влияет ни на какой показатель.
Проверим корреляцию статистически.
def df_corr_chec(df = channel_posts_filtered, first_par = 'er', second_par = 'clear_symbols', alpha=0.05):
"""
Функция для подсчета коэффициентов корреляции Пирсона и Спирмана, а также их уровня значимости
Args:
df(pd.DataFrame): датафрейм,
first_par(str): название столбца, который примем за первый параметр для сравнения
second_par(str): название столбца, который примем за второй параметр сравнения
Returns:
coeffs(str): коэффициенты Пирсона и спирмана, а также уровни значимости и вывод.
"""
kp = pearsonr(df[first_par], df[second_par])
ks = spearmanr(df[first_par], df[second_par])
print(f'pearson_cor: {kp[0]}, pearson_pv: {kp[1]}')
print(f'spearman_cor: {ks[0]}, spearman_pv: {ks[1]}')
if kp[1] < alpha and ks[1] < alpha:
print('Отвергаем нулевую гипотезу, есть основания считать что связь есть')
elif kp[1] >= alpha and ks[1] >= alpha:
print('Не отвергаем нулевую гипотезу, есть основания считать что связи нет')
else:
print('Нельзя однозначно сказать')
Нулевая гипотеза:ρ=0 (связи нет) Альтернативная гипотеза:ρ≠0 (связь есть)
for i in ('clear_symbols', 'clear_punctuation'):
print(i)
df_corr_chec(channel_posts_filtered, 'er', i)
clear_symbols pearson_cor: -0.1244446969521386, pearson_pv: 1.6977161270122093e-26 spearman_cor: -0.11829976695992515, spearman_pv: 4.3835860116619664e-24 Отвергаем нулевую гипотезу, есть основания считать что связь есть clear_punctuation pearson_cor: -0.07223813014170918, pearson_pv: 6.933268896032842e-10 spearman_cor: -0.06830974265548968, spearman_pv: 5.496611166956259e-09 Отвергаем нулевую гипотезу, есть основания считать что связь есть
Хоть статистически показывает, что связь есть, коэффициенты корреляции слишком небольшие, чтобы сказать однозначно.
Проанализируем эмодзи к постам.
Подготовим пустой датафрейм.
emoji_df = pd.DataFrame(columns=['id', 'date', 'emoji', 'count'])
Подготовим функцию для получения id поста, его дату, эмодзи к нему и их количество
def get_reactions(row):
"""
Функция для получения id поста, его даты, реакций и их количества
Args:
row(pd.DataFrame): строка датафрейма,
Returns:
emoji_rows(pd.DataFrame): датафрейм с id поста колонки, его даты, реакций и их количества.
"""
emoji_rows = []
try:
reactions = ast.literal_eval(row['reactions'])
for reaction in reactions['results']:
emoji = reaction['reaction'].get('emoticon', None)
count = reaction['count']
emoji_rows.append({'id': row['id'], 'emoji': emoji, 'count': count, 'date':row['post_date']})
except Exception as e:
print(f"Error: {e}")
return emoji_rows
Применим функцию.
for index, row in channel_posts_filtered.iterrows():
emoji_rows = get_reactions(row)
emoji_df = emoji_df.append(emoji_rows, ignore_index=True)
Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan Error: malformed node or string: nan
print(emoji_df.info())
display(emoji_df.head())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 56360 entries, 0 to 56359 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 id 56360 non-null object 1 date 56360 non-null object 2 emoji 56360 non-null object 3 count 56360 non-null object dtypes: object(4) memory usage: 1.7+ MB None
| id | date | emoji | count | |
|---|---|---|---|---|
| 0 | 37125 | 2024-01-21 | ❤ | 41 |
| 1 | 37125 | 2024-01-21 | 👍 | 8 |
| 2 | 37125 | 2024-01-21 | 🐳 | 4 |
| 3 | 37125 | 2024-01-21 | 👎 | 3 |
| 4 | 37125 | 2024-01-21 | ❤🔥 | 1 |
В нашем распоряжении датафрейм с 4 колонками и 56359 строками.
id - id постаdate - дата постаemoji - реакция к постуcount - количество этих реакций к посту.Посотрим на ТОП10 самых популярных реакций.
top_10_reactions = (emoji_df
.groupby(by='emoji', as_index=False)['count'].sum()
.sort_values(by='count', ascending=False)
.head(10))
display(top_10_reactions)
| emoji | count | |
|---|---|---|
| 18 | 👍 | 846086 |
| 2 | ❤ | 712377 |
| 19 | 👎 | 237739 |
| 29 | 🔥 | 229279 |
| 3 | ❤🔥 | 135063 |
| 39 | 😢 | 115931 |
| 33 | 😁 | 58495 |
| 45 | 🤔 | 37173 |
| 25 | 💔 | 25383 |
| 56 | 🥰 | 21293 |
Абсолютным рекордсменом является палец вверх, на втором месте сердечко, а на третьем палец вниз.
Посмотрим, как менялись реакции по месяцам, для этого добавим столбец месяца к нашему датафрейму.
emoji_df['month'] = pd.to_datetime(emoji_df['date'], format='%Y-%m-%d').dt.to_period('M')
И посчитаем сумму реакций топ3 самых популярных эмодзи по месяцам.
temp = emoji_df.query('emoji in @top_10_reactions.head(3).emoji').pivot_table(
index='month',
values='count',
aggfunc='sum',
columns='emoji')
display(temp)
temp.plot(kind="bar", grid=True, figsize=(15, 5))
plt.xlabel('Месяц и год')
plt.title('Динамика топ3 реакций')
plt.ylabel('Количество реакций')
plt.legend(loc='upper left')
plt.show();
| emoji | ❤ | 👍 | 👎 |
|---|---|---|---|
| month | |||
| 2021-12 | 246 | 62 | 2 |
| 2022-01 | 3322 | 16958 | 1380 |
| 2022-02 | 2618 | 16338 | 4945 |
| 2022-03 | 11231 | 41555 | 11523 |
| 2022-04 | 14184 | 45092 | 11257 |
| 2022-05 | 17065 | 51179 | 9465 |
| 2022-06 | 18118 | 41185 | 8290 |
| 2022-07 | 17729 | 42857 | 7344 |
| 2022-08 | 36200 | 51520 | 8946 |
| 2022-09 | 32074 | 48301 | 8623 |
| 2022-10 | 26708 | 57200 | 4986 |
| 2022-11 | 29163 | 59320 | 7456 |
| 2022-12 | 19000 | 34995 | 7500 |
| 2023-01 | 24577 | 29336 | 8058 |
| 2023-02 | 24156 | 25762 | 5223 |
| 2023-03 | 43197 | 38831 | 10732 |
| 2023-04 | 36660 | 20520 | 7885 |
| 2023-05 | 35394 | 27688 | 15695 |
| 2023-06 | 32954 | 20047 | 12198 |
| 2023-07 | 31859 | 25735 | 8076 |
| 2023-08 | 36944 | 20757 | 12253 |
| 2023-09 | 38941 | 29635 | 16692 |
| 2023-10 | 42592 | 26566 | 15980 |
| 2023-11 | 46231 | 29075 | 15534 |
| 2023-12 | 52876 | 24347 | 10932 |
| 2024-01 | 38338 | 21225 | 6764 |
Из-за недоступности кодировки в графике легенда не так понятна, но:
Мы видим, что до марта 2023 года самой популрной реакцией был палец вверх, а после - сердечко.
Для статистического анализа подготовим функцию, для проведения теста Уилкоксона.
def test_wilcoxon(group1,
group2,
alpha=0.05,
sample_volume=50,
multiplicity = 1,
correction = 'bonferrony'):
"""
Функция для проверки статистической разницы долей тестом Уилкоксона
Args:
group1: первая группа сравнения
group2: вторая группа сравнения
alpha: критический уровень статистической значимости, по умолчанию 0.05
bonferrony: поправка Бонферрони, по умолчанию 1
sample_volume: размер выборки из групп для сравнения
multiplicity: количество сравнений
correction: выбор поправки, по умолчанию Бонферрони, альтернатива - Шидака
Returns:
print(p-значение).
print(результат теста).
"""
# делаем поправку значимости
# на коэффициент Бонферрони, если выбран метод бонферони
if correction == 'bonferrony':
alpha = alpha / multiplicity
# на поправку Шидака, если выбрано другое
else:
alpha = 1 - (1-alpha)**(1/multiplicity)
# приводим наши группы в вид списка
group1_list = group1.tolist()
group2_list = group2.tolist()
# подготавливаем пустые списки для сравнения
sample_a =[]
sample_b = []
# создаем список индексов пустым
counter = []
# пока группы а выборки меньше заданного
while len(sample_a) < sample_volume:
# получаем случайное число от 0 до длины группы
i = random.randrange(0, len(group1_list), 1)
# если числа нет в списке индексов
if i not in counter:
# добавляем значение из группы в список для сравнения
sample_a.append(group1_list[i])
# добавляем ингдекс в счетчик
counter.append(i)
# если индекс есть в счетчике проолжаем дальше
else:
pass
# еще раз создаем список индексов пустым
counter = []
# провеодим все тоже самое для группы 2
while len(sample_b) < sample_volume:
i = random.randrange(0, len(group2_list), 1)
if i not in counter:
sample_b.append(group2_list[i])
counter.append(i)
else:
pass
# выводитм значение p-value
print('p-value равен', '{0:.5f}'.format(stats.wilcoxon(sample_a, sample_b)[1]))
# если p-value больше alpha - не отвергаем нулевую гипотезу
if stats.wilcoxon(sample_a, sample_b)[1] > alpha:
print('Не отвергаем нулевую гипотезу, нет оснований считать группы разными')
# если p-value меньше или равен alpha - отвергаем нулевую гипотезу
else:
print('Отвергаем нулевую гипотезу, есть основания считать группы разными')
# выводим отношение показателей
print('Отношение показателя группы B к A равно ',
'{0:.3f}'.format(statistics.mean(sample_b) / statistics.mean(sample_a) - 1))
Выдвенем нулевую гипотезу, что статистически значимых различий в ER между группами нет.
Альтернативная гипотеза - статистически значимые различия в ER между группами есть.
Проверим ее методом Уилкоксона. Задаем уровень значимости alpha=0.05
i = 'MessageMediaWebPage'
k = 'MessageMediaPhoto'
l = 'MessageMediaDocument'
print(f'ER группы {i} и группы {k}')
test_wilcoxon(channel_posts_filtered.query('type_attachment == @i')['er'],
channel_posts_filtered.query('type_attachment == @k')['er'],
alpha=0.05,
sample_volume=100,
multiplicity = 3,
correction = 'bonferrony')
print()
print(f'ER группы {i} и группы {l}')
test_wilcoxon(channel_posts_filtered.query('type_attachment == @i')['er'],
channel_posts_filtered.query('type_attachment == @l')['er'],
alpha=0.05,
sample_volume=100,
multiplicity = 3,
correction = 'bonferrony')
print()
print(f'ER группы {l} и группы {k}')
test_wilcoxon(channel_posts_filtered.query('type_attachment == @l')['er'],
channel_posts_filtered.query('type_attachment == @k')['er'],
alpha=0.05,
sample_volume=100,
multiplicity = 3,
correction = 'bonferrony')
ER группы MessageMediaWebPage и группы MessageMediaPhoto p-value равен 0.00000 Отвергаем нулевую гипотезу, есть основания считать группы разными Отношение показателя группы B к A равно 0.517 ER группы MessageMediaWebPage и группы MessageMediaDocument p-value равен 0.00000 Отвергаем нулевую гипотезу, есть основания считать группы разными Отношение показателя группы B к A равно 0.653 ER группы MessageMediaDocument и группы MessageMediaPhoto p-value равен 0.87704 Не отвергаем нулевую гипотезу, нет оснований считать группы разными Отношение показателя группы B к A равно 0.151
Мы видим, что видимо есть статистическая разница между поставми с ссылкой и изобрадением а также с ссылкой и документом, при этом статистической разницы в ER между поствми с документом и фото - нет.
Вывод
Одним из основных конкурентов кинопоиска со своим телеграм каналом о кино является wink, посмотрим на посты в его канале.
Т.к. работа с данными похожа, проведем ее кратко.
Загрузим данные.
# подготавливаем ссылки для скачивания файлов с Яндекс.Диска.
# публичная ссылка на датасеты на яндекс диске
folder_url = 'https://disk.yandex.ru/d/nflbLG3sCHE-sg'
# названия файлов
file_url = ['wink_posts_2024-02-21.csv']
# загружаем файл в свой датафрейм
url = ('https://cloud-api.yandex.net/v1/disk/public/resources/download'
+ '?public_key='
+ urllib.parse.quote(folder_url)
+ '&path=/'
+ urllib.parse.quote(file_url[0]))
# запрос ссылки на скачивание
r = requests.get(url)
# 'парсинг' ссылки на скачивание
h = json.loads(r.text)['href']
wink = pd.read_csv(h, index_col=[0])
Уберем лишние колонки.
wink = wink.drop(columns=['channel', 'with_media', 'replies'])
display(wink.sample(2))
print(wink.info())
| id | date | text | views | reactions | forwarded | reactions_count | comments | type_attachment | |
|---|---|---|---|---|---|---|---|---|---|
| 9784 | 160 | 2020-10-14 17:02:47+00:00 | Чем еще порадует нас октябрь? Боем Александра Волкова!💥\n\nЗвезда ММА и бывший чемпион в тяжёлом весе в Bellator и M-1 Challenge на турнире UFC 254 выступит в одном карде Хабибом Нурмагомедовым. На «Бойцовском острове» Волков сразится с Уолтом Харрисом, и этот бой станет для него 40-м по счету.\n\n⚽️ Россиянин настроен решительно и каждый день готовится к поединку, тренируясь вместе с Максимом Гришиным. Ранее Волков уже был замечен в совместных тренировках, только тогда это был Артём Дзюба. Они давно дружат, и даже проводили друг другу тренировки по своим видам спорта: Волков — по ММА, а Дзюба — по футболу. Надеемся, что такая поддержка поможет победе Волкова в поединке на UFC!\n\nА как это будет, смотрите в трансляции на UFC ТВ! https://clck.ru/RPjEE\n\nУзнавайте больше о бойцах UFC из наших анкет и готовьтесь к 24 октября — главному поединку года Хабиб vs Гэтжи — вместе с Wink. | 54.0 | NaN | 1.0 | 0 | 0 | MessageMediaDocument |
| 9050 | 962 | 2021-08-13 14:18:20+00:00 | 🗽 Камео — это небольшая роль известного человека или образа, которая заметно выделяется на фоне других небольших ролей в фильме. \n\nКоролем камео был Альфред Хичкок — он появлялся почти во всех своих фильмах. Появляться в кино в роли самого себя в 90-е любил и Дональд Трамп, тогда еще предприниматель и телеведущий: яркий пример можно увидеть в «Один дома 2: Затерянный в Нью-Йорке». Еще одним любителем камео был Стэн Ли, который сыграл более 50 небольших ролей в игровых и анимационных фильмах вселенной Marvel. | 1376.0 | NaN | 0.0 | 0 | 0 | MessageMediaDocument |
<class 'pandas.core.frame.DataFrame'> Int64Index: 4627 entries, 0 to 9913 Data columns (total 9 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 id 4627 non-null int64 1 date 4627 non-null object 2 text 4627 non-null object 3 views 4627 non-null float64 4 reactions 3081 non-null object 5 forwarded 4627 non-null float64 6 reactions_count 4627 non-null int64 7 comments 4627 non-null int64 8 type_attachment 4218 non-null object dtypes: float64(2), int64(3), object(4) memory usage: 361.5+ KB None
В нашем расположении датафрейм с 8 колонками и 9913 строками.
id - id поста,date - дата написания поста,text - текст поста,views - количество просмотров поста,reactions - количество реакций на посте,forwarded - количество репостов поста,reactions_count - количество реакций на посте (сумма всех),comments - количество комментариев,type_attachment - тип приложения к посту (файл, фото, видео).Добавим колонку date_time с датой и временем поста в формате datetime64, post_date с датой написания поста, а также er c er поста (все взаимодействия с постом на количество просмотров, умноженное на 100).
wink['date_time']= pd.to_datetime(wink['date'], format="%Y-%m-%d %H:%M:%S%z", utc=True).dt.tz_convert('Europe/Moscow')
wink['post_date'] = pd.to_datetime(wink['date_time']).dt.date
wink_filtered = wink.query('@analyzes_date < post_date <= @channel_posts.post_date.max()')
wink_filtered['er'] = ((wink_filtered['reactions_count']
+ wink_filtered['comments']
+ wink_filtered['forwarded'])
* 100
/wink_filtered['views'] ).round(2)
Посмотрим, как часто писали посты в канале.
# посчитаем сколько в каждую дату было написано постов
wink_posts_dimanic = wink.groupby(by='post_date').agg({'id':'count'}).reset_index().sort_values(by='post_date')
# посчитаем кумулятивную сумму, т.е. с накоплением
wink_posts_dimanic['posts_cum'] = wink_posts_dimanic['id'].cumsum()
# посчитаем сколько в каждую дату было написано постов с 30 декаря 2021 года
filter_wink_posts_dimanic = (wink_filtered
.groupby(by='post_date')
.agg({'id':'count'})
.reset_index()
.sort_values(by='post_date')
.rename(columns={'id':'posts_per_day'}))
# посчитаем кумулятивную сумму, т.е. с накоплением
filter_wink_posts_dimanic['posts_cum'] = filter_wink_posts_dimanic['posts_per_day'].cumsum()
fig, ax = plt.subplots(1, 2, figsize=(12,6))
fig.suptitle('Количество постов в канале', fontsize=20)
ax[0].plot(wink_posts_dimanic['post_date'], wink_posts_dimanic['posts_cum'])
ax[0].set_title('Количество написанных постов за все время')
ax[0].set(xlabel='Дата', ylabel='Количество написанных постов к дате')
ax[0].tick_params(axis='x', labelrotation=45)
ax[0].grid(True)
ax[1].plot(filter_wink_posts_dimanic['post_date'], filter_wink_posts_dimanic['posts_cum'])
ax[1].set_title(f'Количество написанных постов c {analyzes_date}')
ax[1].set(xlabel='Дата', ylabel='Количество написанных постов к дате')
ax[1].tick_params(axis='x', labelrotation=45)
ax[1].grid(True)
fig.show();
Посты в канале пишутся довольно равномерно, за анализируемый период было написано около 3600 постов.
Посмотрим медианный ER по дням
wink_er_day = wink_filtered.groupby('post_date', as_index=False).agg({'er':'median'}).sort_values(by='post_date')
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность подписчиков (ER) по дням')
plt.plot(wink_er_day['post_date'], wink_er_day['er'])
plt.xlabel('Дата')
plt.ylabel('er')
plt.grid(True)
plt.show();
Посмотрим медианный ER по месяцам.
wink_er_day['month'] = pd.to_datetime(wink_er_day['post_date'], format='%Y-%m-%d').dt.to_period("M")
wink_er_month = wink_er_day.groupby('month', as_index=False).agg({'er':'median'}).sort_values(by='month')
wink_er_month['month'] = wink_er_month['month'].astype('str')
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность подписчиков (ER) по месяцам')
plt.plot(wink_er_month['month'], wink_er_month['er'])
plt.xlabel('Месяц')
plt.tick_params(axis='x', labelrotation=45)
plt.ylabel('er')
plt.grid(True)
plt.show();
Мы видим, что был небольшой пик в ноябре 2022, после чего падение, затем плавный рост до апреля 2023, и снова падение плавное, и резкий рост с ноября 2023.
wink_filtered['post_hour'] = wink_filtered['date_time'].dt.hour
wink_filtered.groupby(by='post_hour')['er'].median().plot(kind='bar',
figsize=(12,5),
title='Медианный ER по времени написания поста',
xlabel='Время',
ylabel='Медианный ER',
rot=0
);
Мы видим, что медианный ER выше у постов в польночь, но это может быть связано с тем, что ночью пишут мало постов, проверим.
wink_filtered.groupby(by='post_hour')['er'].count().plot(kind='bar',
figsize=(12,5),
title='Количество постов в течение дня',
xlabel='Время',
ylabel='Количество написанных постов',
rot=0
);
Да, действительно, в канале почти нет постов с 10 вечера до 9 утра.
Посмотрим на медианный ER по дням недели.
wink_filtered['post_weekday'] = wink_filtered['date_time'].dt.dayofweek
wink_filtered.groupby(by='post_weekday')['er'].median().plot(kind='bar',
figsize=(12,5),
title='Медианный ER по дню недели написания поста',
xlabel='День недели',
ylabel='Медианный ER',
rot=0
);
Можно сказать, что ER почти равен по дням недели, есть падение сильное только в воскресенье, а больше всего в понедельник.
Посчитаем количество символов и знаков препинания в текстах постов.
clean_data(wink_filtered)
wink_filtered['clear_symbols'] = wink_filtered['clean_text'].apply(lambda x: len(x))
wink_filtered['clear_punctuation'] = wink_filtered['clean_text'].apply(lambda x: sum(map(x.count, [',',
'.',
'—',
':',
';',
'-',
'!',
'?'])))
Разобьем посты по количеству символов.
wink_filtered['post_cat'] = wink_filtered['clear_symbols'].apply(post_volume)
display(wink_filtered.groupby(by='post_cat', as_index=False)['id'].count())
wink_filtered['post_cat'].value_counts().plot(kind="pie",
autopct='%1.1f%%',
explode=[0.05, 0.01, 0.01, 0.01],
legend=True,
title='Доля постов по категориям',
ylabel='',
labeldistance=None,
figsize=(6, 6),
cmap='Pastel2').legend(bbox_to_anchor=(1.5, 1));
| post_cat | id | |
|---|---|---|
| 0 | длинный пост | 139 |
| 1 | короткий пост | 2567 |
| 2 | обычный пост | 748 |
| 3 | очень короткий пост | 133 |
Мы видим, что 71,6% постов были короткими, на втором месте посты обычной длины (до 1000 символов) - 20,9%, 3,9% - длинные посты, 3,7% - очень короткие посты.
wink_filtered.groupby(by='post_cat')['er'].median().sort_values(ascending=False).plot(kind='bar',
figsize=(12,5),
title='Медианный ER по категориям постов',
xlabel='Категория поста',
ylabel='Медианный ER',
rot=0
);
У очень коротких постов выше медианный охват, это логично, так выкладывают трейлеры, постеры, кадры из фильмов, срочные новости. Разница с кинопоиском в том, что медианный ахват в этом канале выше у длинных постов, чем у постов с обычной длиной.
wink_filtered.groupby(by='post_cat')['views'].median().sort_values(ascending=False).plot(kind='bar',
figsize=(12,5),
title='Медианное количество просмотров по категориям постов',
xlabel='Категория поста',
ylabel='Медианное количество просмотров',
rot=0
);
Также просмотров больше всего у очень коротких постов, а у длинных постов меньше всего.
fig, axes = plt.subplots(nrows=4, figsize = (10, 15))
fig.tight_layout(pad=5.0)
# ER
ax1 = sns.boxplot(x='er', y='post_cat', data=wink_filtered, color='#82E0AA', ax=axes[0])
ax1.set(title = 'ER по категориям постов', ylabel = 'Категория', xlabel = 'ER')
# Просмотры
ax2 = sns.boxplot(x='views', y='post_cat', data=wink_filtered, color='#85C1E9', ax=axes[1])
ax2.set(title = 'Просмотов по категориям постов', ylabel = 'Категория', xlabel = 'Количество просмотров')
# Репосты
ax3 = sns.boxplot(x='forwarded', y='post_cat', data=wink_filtered, color='#F5B7B1', ax=axes[2])
ax3.set(title = 'Репостов по категориям постов', ylabel = 'Категория', xlabel = 'Количество репостов')
# Комментарии
ax4 = sns.boxplot(x='comments', y='post_cat', data=wink_filtered, color='#FAD7A0', ax=axes[3])
ax4.set(title = 'Комментариев по категориям постов', ylabel = 'Категория', xlabel = 'Количество комментариев')
plt.show()
fig, axes = plt.subplots(nrows=4, figsize = (10, 15))
fig.tight_layout(pad=5.0)
# ER
ax1 = sns.boxplot(x='er', y='post_cat', data=wink_filtered, color='#82E0AA', ax=axes[0], showfliers=False)
ax1.set(title = 'ER по категориям постов', ylabel = 'Категория', xlabel = 'ER')
# Просмотры
ax2 = sns.boxplot(x='views', y='post_cat', data=wink_filtered, color='#85C1E9', ax=axes[1], showfliers=False)
ax2.set(title = 'Просмотов по категориям постов', ylabel = 'Категория', xlabel = 'Количество просмотров')
# Репосты
ax3 = sns.boxplot(x='forwarded', y='post_cat', data=wink_filtered, color='#F5B7B1', ax=axes[2], showfliers=False)
ax3.set(title = 'Репостов по категориям постов', ylabel = 'Категория', xlabel = 'Количество репостов')
# Комментарии
ax4 = sns.boxplot(x='comments', y='post_cat', data=wink_filtered, color='#FAD7A0', ax=axes[3], showfliers=False)
ax4.set(title = 'Комментариев по категориям постов', ylabel = 'Категория', xlabel = 'Количество комментариев')
plt.show()
Пользователям интереснее всего короткие и очень короткие посты, они с ними больше всего взаимодейстуют.
min_er_text_wink = ' '.join(wink_filtered.query('er <= @wink_filtered.er.median()')['clean_text'])
max_er_text_wink = ' '.join(wink_filtered.query('er > @wink_filtered.er.median()')['clean_text'])
wink_er_min_noun_tokens, wink_er_min_adjf_tokens, wink_er_min_verb_tokens = get_word_tokens(min_er_text_wink)
wink_er_max_noun_tokens, wink_er_max_adjf_tokens, wink_er_max_verb_tokens = get_word_tokens(max_er_text_wink)
ER меньше или равен медианному значению
words_cloud(wink_er_min_noun_tokens)
ER больше медианного значения
words_cloud(wink_er_max_noun_tokens)
Выглядит так, что пользователям этого канала больше нравятся посты о сериалах, особенно о слове пацана.
ER меньше или равен медианному значению
words_cloud(wink_er_min_adjf_tokens)
ER больше медианного значения
words_cloud(wink_er_max_adjf_tokens)
В прилагательных нет разницы.
ER меньше или равен медианному значению
words_cloud(wink_er_min_verb_tokens)
ER больше медианного значения
words_cloud(wink_er_max_verb_tokens)
В глаголах тоже, можно сказать, нет разницы.
Посмотрим, как влияет тип вложения на ER.
Заполним пропуски в типе вложения "without_attachment", т.е. без вложения.
wink_filtered['type_attachment'] = wink_filtered['type_attachment'].fillna('without_attachment')
Посчитаем количество постов по типу вложения и их медианный ER.
wink_attach = wink_filtered.groupby(by='type_attachment').agg({'id':'count', 'er':'median'})
display(wink_attach)
| id | er | |
|---|---|---|
| type_attachment | ||
| MessageMediaDocument | 741 | 1.210 |
| MessageMediaPhoto | 2090 | 0.640 |
| MessageMediaWebPage | 418 | 0.635 |
| without_attachment | 338 | 0.345 |
wink_attach['id'].sort_values(ascending=False).plot(kind="pie",
autopct='%1.1f%%',
explode=[0.05, 0.01, 0.01, 0.01],
legend=True,
title='Доля постов по вложениям',
ylabel='',
labeldistance=None,
figsize=(6, 6),
cmap='Pastel2').legend(bbox_to_anchor=(1.5, 1));
Более половины (58,3%) постов в канале с картинками, 20,7% с документом, меньше всего (9,4%) постов без вложения.
wink_attach['er'].sort_values(ascending=False).plot(kind='bar',
figsize=(12,5),
title='Медианное ER просмотров по типу вложения',
xlabel='Тип вложения',
ylabel='Медианное ER',
rot=0
);
При этом наибольший медианный ER у постов с документами (почти в 2 раза, по сравнению с изображениями и ссылками), наименьший медианный ER у постов без вложения.
Вывод
Также посмотрим на непрямого конкурента в бизнессе, но тоже крупный канал про кино в телеграме.
# подготавливаем ссылки для скачивания файлов с Яндекс.Диска.
# публичная ссылка на датасеты на яндекс диске
folder_url = 'https://disk.yandex.ru/d/nflbLG3sCHE-sg'
# названия файлов
file_url = ['kinoreel_posts_2024-02-21.csv']
# загружаем файл в свой датафрейм
url = ('https://cloud-api.yandex.net/v1/disk/public/resources/download'
+ '?public_key='
+ urllib.parse.quote(folder_url)
+ '&path=/'
+ urllib.parse.quote(file_url[0]))
# запрос ссылки на скачивание
r = requests.get(url)
# 'парсинг' ссылки на скачивание
h = json.loads(r.text)['href']
kinoreel = pd.read_csv(h, index_col=[0])
kinoreel = kinoreel.drop(columns=['channel', 'with_media', 'replies'])
print(kinoreel.info())
display(kinoreel.sample(2))
<class 'pandas.core.frame.DataFrame'> Int64Index: 2177 entries, 0 to 3925 Data columns (total 9 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 id 2177 non-null int64 1 date 2177 non-null object 2 text 2177 non-null object 3 views 2177 non-null float64 4 reactions 2147 non-null object 5 forwarded 2177 non-null float64 6 reactions_count 2177 non-null int64 7 comments 2177 non-null int64 8 type_attachment 2159 non-null object dtypes: float64(2), int64(3), object(4) memory usage: 170.1+ KB None
| id | date | text | views | reactions | forwarded | reactions_count | comments | type_attachment | |
|---|---|---|---|---|---|---|---|---|---|
| 3924 | 3 | 2022-04-05 07:05:16+00:00 | По сводкам портала Deadline, актриса Сидни Суидни, известная по роли Кесси в нашумевшем подростковом сериале “Эйфория”, присоединилась к касту экранизации комикса Marvel «Мадам Паутина». Главную роль, как уже известно, сыграет звезда «50 оттенков серого» Дакота Джонсон, роль Суини пока неизвестна. \n\nМадам Паутина, она же Кассандра Уэбб, обладает экстрасенсорными способностями и даром ясновидения, которые позволили ей стать медиумом. Именно Мадам Паутина раскрыла личность Питера Паркера в комиксах, но при таком большом вкладе героиня всё равно оставалась второстепенной. \n\nКак будет выглядеть сольный проект и каких ещё героев увидим на экране – узнаем позднее. | 81706.0 | {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 125, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 18, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👎'}, 'count': 7, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🤔'}, 'count': 7, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🦄'}, 'count': 7, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '😭'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🙏'}, 'count': 2, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👾'}, 'count': 1, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'reactions_as_tags': False, 'recent_reactions': []} | 16.0 | 170 | 10 | MessageMediaPhoto |
| 1192 | 2828 | 2023-08-24 05:02:09+00:00 | Доброе утро, жители KinoReel! \nПоделитесь в комментариях любимыми киномемами, давайте поднимем друг другу настроение :) | 14268.0 | {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 52, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '😁'}, 'count': 37, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 10, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👌'}, 'count': 4, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'reactions_as_tags': False, 'recent_reactions': []} | 24.0 | 103 | 13 | MessageMediaPhoto |
kinoreel['date_time']= pd.to_datetime(kinoreel['date'], format="%Y-%m-%d %H:%M:%S%z", utc=True).dt.tz_convert('Europe/Moscow')
kinoreel['post_date'] = pd.to_datetime(kinoreel['date_time']).dt.date
kinoreel_filtered = kinoreel.query('@analyzes_date < post_date <= @channel_posts.post_date.max()')
kinoreel_filtered['er'] = ((kinoreel_filtered['reactions_count']
+ kinoreel_filtered['comments']
+ kinoreel_filtered['forwarded'])
* 100
/kinoreel_filtered['views'] ).round(2)
# посчитаем сколько в каждую дату было написано постов
kinoreel_posts_dimanic = kinoreel.groupby(by='post_date').agg({'id':'count'}).reset_index().sort_values(by='post_date')
# посчитаем кумулятивную сумму, т.е. с накоплением
kinoreel_posts_dimanic['posts_cum'] = kinoreel_posts_dimanic['id'].cumsum()
# посчитаем сколько в каждую дату было написано постов с 30 декаря 2021 года
filter_kinoreel_posts_dimanic = (kinoreel_filtered
.groupby(by='post_date')
.agg({'id':'count'})
.reset_index()
.sort_values(by='post_date')
.rename(columns={'id':'posts_per_day'}))
# посчитаем кумулятивную сумму, т.е. с накоплением
filter_kinoreel_posts_dimanic['posts_cum'] = filter_kinoreel_posts_dimanic['posts_per_day'].cumsum()
fig, ax = plt.subplots(1, 2, figsize=(12,6))
fig.suptitle('Количество постов в канале', fontsize=20)
ax[0].plot(kinoreel_posts_dimanic['post_date'], kinoreel_posts_dimanic['posts_cum'])
ax[0].set_title('Количество написанных постов за все время')
ax[0].set(xlabel='Дата', ylabel='Количество написанных постов к дате')
ax[0].tick_params(axis='x', labelrotation=45)
ax[0].grid(True)
ax[1].plot(filter_kinoreel_posts_dimanic['post_date'], filter_kinoreel_posts_dimanic['posts_cum'])
ax[1].set_title(f'Количество написанных постов c {analyzes_date}')
ax[1].set(xlabel='Дата', ylabel='Количество написанных постов к дате')
ax[1].tick_params(axis='x', labelrotation=45)
ax[1].grid(True)
fig.show();
kinoreel_er_day = kinoreel_filtered.groupby('post_date', as_index=False).agg({'er':'mean'}).sort_values(by='post_date')
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность подписчиков (ER) по дням')
plt.plot(kinoreel_er_day['post_date'], kinoreel_er_day['er'])
plt.xlabel('Дата')
plt.ylabel('er')
plt.grid(True)
plt.show();
kinoreel_er_day['month'] = pd.to_datetime(kinoreel_er_day['post_date'], format='%Y-%m-%d').dt.to_period("M")
kinoreel_er_month = kinoreel_er_day.groupby('month', as_index=False).agg({'er':'median'}).sort_values(by='month')
kinoreel_er_month['month'] = kinoreel_er_month['month'].astype('str')
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность подписчиков (ER) по месяцам')
plt.plot(kinoreel_er_month['month'], kinoreel_er_month['er'])
plt.xlabel('Месяц')
plt.tick_params(axis='x', labelrotation=45)
plt.ylabel('er')
plt.grid(True)
plt.show();
kinoreel_filtered['post_hour'] = kinoreel_filtered['date_time'].dt.hour
kinoreel_filtered.groupby(by='post_hour')['er'].median().plot(kind='bar',
figsize=(12,5),
title='Медианный ER по времени написания поста',
xlabel='Время',
ylabel='Медианный ER',
rot=0
);
kinoreel_filtered['post_weekday'] = kinoreel_filtered['date_time'].dt.dayofweek
kinoreel_filtered.groupby(by='post_weekday')['er'].median().plot(kind='bar',
figsize=(12,5),
title='Медианный ER по дню недели написания поста',
xlabel='День недели',
ylabel='Медианный ER',
rot=0
);
clean_data(kinoreel_filtered)
kinoreel_filtered['clear_symbols'] = kinoreel_filtered['clean_text'].apply(lambda x: len(x))
kinoreel_filtered['clear_punctuation'] = kinoreel_filtered['clean_text'].apply(lambda x: sum(map(x.count, [',',
'.',
'—',
':',
';',
'-',
'!',
'?'])))
kinoreel_filtered['post_cat'] = kinoreel_filtered['clear_symbols'].apply(post_volume)
display(kinoreel_filtered.groupby(by='post_cat', as_index=False)['id'].count())
kinoreel_filtered['post_cat'].value_counts().plot(kind="pie",
autopct='%1.1f%%',
explode=[0.05, 0.01, 0.01, 0.01],
legend=True,
title='Доля постов по категориям',
ylabel='',
labeldistance=None,
figsize=(6, 6),
cmap='Pastel2').legend(bbox_to_anchor=(1.5, 1));
| post_cat | id | |
|---|---|---|
| 0 | длинный пост | 222 |
| 1 | короткий пост | 959 |
| 2 | обычный пост | 663 |
| 3 | очень короткий пост | 167 |
kinoreel_filtered.groupby(by='post_cat')['er'].median().sort_values(ascending=False).plot(kind='bar',
figsize=(12,5),
title='Медианный ER по категориям постов',
xlabel='Категория поста',
ylabel='Медианный ER',
rot=0
);
kinoreel_filtered.groupby(by='post_cat')['views'].median().sort_values(ascending=False).plot(kind='bar',
figsize=(12,5),
title='Медианное количество просмотров по категориям постов',
xlabel='Категория поста',
ylabel='Медианное количество просмотров',
rot=0
);
fig, axes = plt.subplots(nrows=4, figsize = (10, 15))
fig.tight_layout(pad=5.0)
# ER
ax1 = sns.boxplot(x='er', y='post_cat', data=kinoreel_filtered, color='#82E0AA', ax=axes[0])
ax1.set(title = 'ER по категориям постов', ylabel = 'Категория', xlabel = 'ER')
# Просмотры
ax2 = sns.boxplot(x='views', y='post_cat', data=kinoreel_filtered, color='#85C1E9', ax=axes[1])
ax2.set(title = 'Просмотов по категориям постов', ylabel = 'Категория', xlabel = 'Количество просмотров')
# Репосты
ax3 = sns.boxplot(x='forwarded', y='post_cat', data=kinoreel_filtered, color='#F5B7B1', ax=axes[2])
ax3.set(title = 'Репостов по категориям постов', ylabel = 'Категория', xlabel = 'Количество репостов')
# Комментарии
ax4 = sns.boxplot(x='comments', y='post_cat', data=kinoreel_filtered, color='#FAD7A0', ax=axes[3])
ax4.set(title = 'Комментариев по категориям постов', ylabel = 'Категория', xlabel = 'Количество комментариев')
plt.show()
fig, axes = plt.subplots(nrows=4, figsize = (10, 15))
fig.tight_layout(pad=5.0)
# ER
ax1 = sns.boxplot(x='er', y='post_cat', data=kinoreel_filtered, color='#82E0AA', ax=axes[0], showfliers=False)
ax1.set(title = 'ER по категориям постов', ylabel = 'Категория', xlabel = 'ER')
# Просмотры
ax2 = sns.boxplot(x='views', y='post_cat', data=kinoreel_filtered, color='#85C1E9', ax=axes[1], showfliers=False)
ax2.set(title = 'Просмотов по категориям постов', ylabel = 'Категория', xlabel = 'Количество просмотров')
# Репосты
ax3 = sns.boxplot(x='forwarded', y='post_cat', data=kinoreel_filtered, color='#F5B7B1', ax=axes[2], showfliers=False)
ax3.set(title = 'Репостов по категориям постов', ylabel = 'Категория', xlabel = 'Количество репостов')
# Комментарии
ax4 = sns.boxplot(x='comments', y='post_cat', data=kinoreel_filtered, color='#FAD7A0', ax=axes[3], showfliers=False)
ax4.set(title = 'Комментариев по категориям постов', ylabel = 'Категория', xlabel = 'Количество комментариев')
plt.show()
min_er_text_kinoreel = ' '.join(kinoreel_filtered.query('er <= @kinoreel_filtered.er.median()')['clean_text'])
max_er_text_kinoreel = ' '.join(kinoreel_filtered.query('er > @kinoreel_filtered.er.median()')['clean_text'])
kinoreel_er_min_noun_tokens, kinoreel_er_min_adjf_tokens, kinoreel_er_min_verb_tokens = get_word_tokens(min_er_text_kinoreel)
kinoreel_er_max_noun_tokens, kinoreel_er_max_adjf_tokens, kinoreel_er_max_verb_tokens = get_word_tokens(max_er_text_kinoreel)
ER меньше или равен медианному значению
words_cloud(kinoreel_er_min_noun_tokens)
ER больше медианного значения
words_cloud(kinoreel_er_max_noun_tokens)
ER меньше или равен медианному значению
words_cloud(kinoreel_er_min_adjf_tokens)
ER больше медианного значения
words_cloud(kinoreel_er_max_adjf_tokens)
ER меньше или равен медианному значению
words_cloud(kinoreel_er_min_verb_tokens)
ER больше медианного значения
words_cloud(kinoreel_er_max_verb_tokens)
kinoreel_filtered['type_attachment'] = kinoreel_filtered['type_attachment'].fillna('without_attachment')
kinoreel_attach = kinoreel_filtered.groupby(by='type_attachment').agg({'id':'count', 'er':'median'})
kinoreel_attach
| id | er | |
|---|---|---|
| type_attachment | ||
| MessageMediaDocument | 291 | 1.36 |
| MessageMediaPhoto | 1450 | 1.09 |
| MessageMediaWebPage | 253 | 0.84 |
| without_attachment | 17 | 0.64 |
kinoreel_attach['id'].sort_values(ascending=False).plot(kind="pie",
autopct='%1.1f%%',
explode=[0.05, 0.01, 0.01, 0.01],
legend=True,
title='Доля постов по вложениям',
ylabel='',
labeldistance=None,
figsize=(6, 6),
cmap='Pastel2').legend(bbox_to_anchor=(1.5, 1));
kinoreel_attach['er'].sort_values(ascending=False).plot(kind='bar',
figsize=(12,5),
title='Медианное ER просмотров по типу вложения',
xlabel='Тип вложения',
ylabel='Медианное ER',
rot=0
);